Skip to content

Commit d7800ac

Browse files
Copilotjogibear9988
andcommitted
Add identity mode formatting for newly added AST nodes
When AST nodes without raw formatting properties (newly created, not parsed) are encountered in identity mode, they are now formatted using the beautified output style with correct indentation, while preserving original formatting for existing parsed nodes. Key changes: - Track indent level in all identity-mode block methods - Add isNewNode() to detect nodes without raw formatting - Add identityVisitBlock() to handle mixed old/new nodes with proper separators and trailing whitespace - Fix comment() to skip indent for parsed comments in identity mode - Add comprehensive tests for the new functionality Co-authored-by: jogibear9988 <364896+jogibear9988@users.noreply.github.com>
1 parent 55f3351 commit d7800ac

2 files changed

Lines changed: 378 additions & 49 deletions

File tree

src/stringify/compiler.ts

Lines changed: 187 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -169,12 +169,14 @@ class Compiler {
169169
rawPrelude?: string,
170170
) {
171171
if (this.identity && rawPrelude) {
172-
return (
172+
this.indent(1);
173+
const result =
173174
this.emit(rawPrelude, position) +
174175
this.emit('{') +
175-
this.mapVisit(this.filterEmptyRules(rules)) +
176-
this.emit('}')
177-
);
176+
this.identityVisitBlock(this.filterEmptyRules(rules), 'rules') +
177+
this.emit('}');
178+
this.indent(-1);
179+
return result;
178180
}
179181
const filteredRules = this.filterEmptyRules(this.stripWhitespace(rules));
180182
if (this.compress) {
@@ -203,12 +205,14 @@ class Compiler {
203205
rawPrelude?: string,
204206
) {
205207
if (this.identity && rawPrelude) {
206-
return (
208+
this.indent(1);
209+
const result =
207210
this.emit(rawPrelude, position) +
208211
this.emit('{') +
209-
this.mapVisit(declarations) +
210-
this.emit('}')
211-
);
212+
this.identityVisitBlock(declarations, 'decls') +
213+
this.emit('}');
214+
this.indent(-1);
215+
return result;
212216
}
213217
const stripped = this.stripWhitespace(declarations);
214218
if (this.compress) {
@@ -253,7 +257,7 @@ class Compiler {
253257
// Fallback to beautified when preserveFormatting was not used
254258
return this.stylesheet(node);
255259
}
256-
return this.filterEmptyRules(rules).map(this.visit, this).join('');
260+
return this.identityVisitBlock(rules as CssAllNodesAST[], 'stylesheet');
257261
}
258262

259263
/**
@@ -293,6 +297,111 @@ class Compiler {
293297
});
294298
}
295299

300+
/**
301+
* Check whether a node was newly created (not parsed from source).
302+
* New nodes lack the raw formatting properties set by the parser.
303+
*/
304+
private isNewNode(node: CssAllNodesAST): boolean {
305+
switch (node.type) {
306+
case CssTypes.whitespace:
307+
return false;
308+
case CssTypes.comment:
309+
return !(node as CssCommonPositionAST).position;
310+
case CssTypes.declaration:
311+
return (node as CssDeclarationAST).rawBetween == null;
312+
case CssTypes.rule:
313+
return (node as CssRuleAST).rawPrelude == null;
314+
default:
315+
if ('rawPrelude' in node)
316+
return !(node as { rawPrelude?: string }).rawPrelude;
317+
if ('rawSource' in node)
318+
return !(node as { rawSource?: string }).rawSource;
319+
return !(node as CssCommonPositionAST).position;
320+
}
321+
}
322+
323+
/**
324+
* Visit block children in identity mode, formatting newly added nodes
325+
* with beautified output while preserving original formatting for
326+
* existing nodes.
327+
*
328+
* @param nodes - The child nodes to visit
329+
* @param context - 'decls' for declaration blocks, 'rules' for nested
330+
* rule blocks (e.g. @media), 'stylesheet' for top-level
331+
*/
332+
private identityVisitBlock(
333+
nodes: Array<CssAllNodesAST>,
334+
context: 'decls' | 'rules' | 'stylesheet',
335+
): string {
336+
const filtered = this.filterEmptyRules(nodes);
337+
338+
// Fast path: no new nodes – visit normally
339+
if (!filtered.some((n) => this.isNewNode(n))) {
340+
return this.mapVisit(filtered);
341+
}
342+
343+
let buf = '';
344+
const needsDoubleNewline = context === 'rules' || context === 'stylesheet';
345+
346+
for (let i = 0; i < filtered.length; i++) {
347+
const node = filtered[i];
348+
349+
if (!this.isNewNode(node)) {
350+
buf += this.visit(node);
351+
continue;
352+
}
353+
354+
// ── New node: add proper separator before it ──
355+
356+
// Check whether the buffer already ends with \n (possibly followed
357+
// by spaces that were originally the closing-brace indent). If so,
358+
// trim those trailing spaces – the beautified fallback of the new
359+
// node will supply the correct indentation.
360+
const lastNl = buf.lastIndexOf('\n');
361+
const hasTrailingNewline =
362+
lastNl >= 0 && buf.slice(lastNl + 1).trim() === '';
363+
364+
if (hasTrailingNewline) {
365+
buf = buf.slice(0, lastNl + 1);
366+
} else if (buf.length === 0 && context !== 'stylesheet') {
367+
// First child in a block – add newline after opening brace
368+
buf += '\n';
369+
} else if (buf.length > 0) {
370+
buf += '\n';
371+
}
372+
373+
if (needsDoubleNewline && buf.length > 1 && !buf.endsWith('\n\n')) {
374+
buf += '\n';
375+
}
376+
377+
// Visit the node (uses beautified fallback since raw props are absent)
378+
const str = this.visit(node);
379+
if (str) buf += str;
380+
}
381+
382+
// Add trailing newline + closing-brace indent when the last meaningful
383+
// node is new (blocks only, not the top-level stylesheet)
384+
if (context !== 'stylesheet') {
385+
const lastMeaningful = [...filtered]
386+
.reverse()
387+
.find((n) => n.type !== CssTypes.whitespace);
388+
if (
389+
lastMeaningful &&
390+
this.isNewNode(lastMeaningful) &&
391+
buf.length > 0 &&
392+
!buf.endsWith('\n')
393+
) {
394+
buf += '\n';
395+
// Indent for the closing brace (one level less than current)
396+
if (this.level > 2) {
397+
buf += this.indentation.repeat(this.level - 2);
398+
}
399+
}
400+
}
401+
402+
return buf;
403+
}
404+
296405
/**
297406
* Visit whitespace node.
298407
*/
@@ -311,6 +420,9 @@ class Compiler {
311420
if (this.compress) {
312421
return this.emit('', node.position);
313422
}
423+
if (this.identity && node.position) {
424+
return this.emit(`/*${node.comment}*/`, node.position);
425+
}
314426
return this.emit(`${this.indent()}/*${node.comment}*/`, node.position);
315427
}
316428

@@ -331,12 +443,17 @@ class Compiler {
331443
*/
332444
layer(node: CssLayerAST) {
333445
if (this.identity && node.rawPrelude && node.rules) {
334-
return (
446+
this.indent(1);
447+
const result =
335448
this.emit(node.rawPrelude, node.position) +
336449
this.emit('{') +
337-
this.mapVisit(this.filterEmptyRules(<CssAllNodesAST[]>node.rules)) +
338-
this.emit('}')
339-
);
450+
this.identityVisitBlock(
451+
this.filterEmptyRules(<CssAllNodesAST[]>node.rules),
452+
'rules',
453+
) +
454+
this.emit('}');
455+
this.indent(-1);
456+
return result;
340457
}
341458
if (this.identity && !node.rules && node.rawSource) {
342459
return this.emit(node.rawSource, node.position);
@@ -388,12 +505,14 @@ class Compiler {
388505
document(node: CssDocumentAST) {
389506
const doc = `@${node.vendor || ''}document ${node.document}`;
390507
if (this.identity && node.rawPrelude) {
391-
return (
508+
this.indent(1);
509+
const result =
392510
this.emit(node.rawPrelude, node.position) +
393511
this.emit('{') +
394-
this.mapVisit(this.filterEmptyRules(node.rules)) +
395-
this.emit('}')
396-
);
512+
this.identityVisitBlock(this.filterEmptyRules(node.rules), 'rules') +
513+
this.emit('}');
514+
this.indent(-1);
515+
return result;
397516
}
398517
const rules = this.stripWhitespace(node.rules);
399518
if (this.compress) {
@@ -461,12 +580,14 @@ class Compiler {
461580
*/
462581
keyframes(node: CssKeyframesAST) {
463582
if (this.identity && node.rawPrelude) {
464-
return (
583+
this.indent(1);
584+
const result =
465585
this.emit(node.rawPrelude, node.position) +
466586
this.emit('{') +
467-
this.mapVisit(node.keyframes) +
468-
this.emit('}')
469-
);
587+
this.identityVisitBlock(node.keyframes, 'rules') +
588+
this.emit('}');
589+
this.indent(-1);
590+
return result;
470591
}
471592
const frames = this.stripWhitespace(node.keyframes);
472593
if (this.compress) {
@@ -494,12 +615,14 @@ class Compiler {
494615
keyframe(node: CssKeyframeAST) {
495616
const decls = node.declarations;
496617
if (this.identity && node.rawPrelude) {
497-
return (
618+
this.indent(1);
619+
const result =
498620
this.emit(node.rawPrelude, node.position) +
499621
this.emit('{') +
500-
this.mapVisit(decls) +
501-
this.emit('}')
502-
);
622+
this.identityVisitBlock(decls, 'decls') +
623+
this.emit('}');
624+
this.indent(-1);
625+
return result;
503626
}
504627
const stripped = this.stripWhitespace(decls);
505628
if (this.compress) {
@@ -525,12 +648,14 @@ class Compiler {
525648
*/
526649
page(node: CssPageAST) {
527650
if (this.identity && node.rawPrelude) {
528-
return (
651+
this.indent(1);
652+
const result =
529653
this.emit(node.rawPrelude, node.position) +
530654
this.emit('{') +
531-
this.mapVisit(node.declarations) +
532-
this.emit('}')
533-
);
655+
this.identityVisitBlock(node.declarations, 'decls') +
656+
this.emit('}');
657+
this.indent(-1);
658+
return result;
534659
}
535660
const decls = this.stripWhitespace(node.declarations);
536661
if (this.compress) {
@@ -560,12 +685,14 @@ class Compiler {
560685
*/
561686
pageMarginBox(node: CssPageMarginBoxAST) {
562687
if (this.identity && node.rawPrelude) {
563-
return (
688+
this.indent(1);
689+
const result =
564690
this.emit(node.rawPrelude, node.position) +
565691
this.emit('{') +
566-
this.mapVisit(node.declarations) +
567-
this.emit('}')
568-
);
692+
this.identityVisitBlock(node.declarations, 'decls') +
693+
this.emit('}');
694+
this.indent(-1);
695+
return result;
569696
}
570697
const decls = this.stripWhitespace(node.declarations);
571698
if (this.compress) {
@@ -603,12 +730,14 @@ class Compiler {
603730
*/
604731
host(node: CssHostAST) {
605732
if (this.identity && node.rawPrelude) {
606-
return (
733+
this.indent(1);
734+
const result =
607735
this.emit(node.rawPrelude, node.position) +
608736
this.emit('{') +
609-
this.mapVisit(this.filterEmptyRules(node.rules)) +
610-
this.emit('}')
611-
);
737+
this.identityVisitBlock(this.filterEmptyRules(node.rules), 'rules') +
738+
this.emit('}');
739+
this.indent(-1);
740+
return result;
612741
}
613742
const rules = this.stripWhitespace(node.rules);
614743
if (this.compress) {
@@ -681,12 +810,14 @@ class Compiler {
681810
*/
682811
scope(node: CssScopeAST) {
683812
if (this.identity && node.rawPrelude) {
684-
return (
813+
this.indent(1);
814+
const result =
685815
this.emit(node.rawPrelude, node.position) +
686816
this.emit('{') +
687-
this.mapVisit(this.filterEmptyRules(node.rules)) +
688-
this.emit('}')
689-
);
817+
this.identityVisitBlock(this.filterEmptyRules(node.rules), 'rules') +
818+
this.emit('}');
819+
this.indent(-1);
820+
return result;
690821
}
691822
const prelude = node.scope ? ` ${node.scope}` : '';
692823
return this.rulesBlock(
@@ -725,12 +856,17 @@ class Compiler {
725856
*/
726857
genericAtRule(node: CssGenericAtRuleAST) {
727858
if (this.identity && node.rawPrelude && node.rules) {
728-
return (
859+
this.indent(1);
860+
const result =
729861
this.emit(node.rawPrelude, node.position) +
730862
this.emit('{') +
731-
this.mapVisit(this.filterEmptyRules(<CssAllNodesAST[]>node.rules)) +
732-
this.emit('}')
733-
);
863+
this.identityVisitBlock(
864+
this.filterEmptyRules(<CssAllNodesAST[]>node.rules),
865+
'rules',
866+
) +
867+
this.emit('}');
868+
this.indent(-1);
869+
return result;
734870
}
735871
const prelude = node.prelude ? ` ${node.prelude}` : '';
736872
const rules = node.rules
@@ -772,12 +908,14 @@ class Compiler {
772908
const decls = node.declarations;
773909

774910
if (this.identity && node.rawPrelude) {
775-
return (
911+
this.indent(1);
912+
const result =
776913
this.emit(node.rawPrelude, node.position) +
777914
this.emit('{') +
778-
this.mapVisit(decls) +
779-
this.emit('}')
780-
);
915+
this.identityVisitBlock(decls, 'decls') +
916+
this.emit('}');
917+
this.indent(-1);
918+
return result;
781919
}
782920

783921
const stripped = this.stripWhitespace(decls);

0 commit comments

Comments
 (0)