Skip to content

Commit 0b65df0

Browse files
authored
fix(material/schematics): don't migrate commented code in theming API migration (#23004)
Adds some logic that escapes comments so that the theming API migration doesn't pick them up.
1 parent a049de4 commit 0b65df0

File tree

2 files changed

+160
-28
lines changed

2 files changed

+160
-28
lines changed

src/material/schematics/ng-update/migrations/theming-api-v12/migration.ts

Lines changed: 75 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,11 +29,20 @@ interface ExtraSymbols {
2929
variables?: Record<string, string>;
3030
}
3131

32+
/** Possible pairs of comment characters in a Sass file. */
33+
const commentPairs = new Map<string, string>([['/*', '*/'], ['//', '\n']]);
34+
35+
/** Prefix for the placeholder that will be used to escape comments. */
36+
const commentPlaceholderStart = '__<<ngThemingMigrationEscapedComment';
37+
38+
/** Suffix for the comment escape placeholder. */
39+
const commentPlaceholderEnd = '>>__';
40+
3241
/**
3342
* Migrates the content of a file to the new theming API. Note that this migration is using plain
3443
* string manipulation, rather than the AST from PostCSS and the schematics string manipulation
3544
* APIs, because it allows us to run it inside g3 and to avoid introducing new dependencies.
36-
* @param content Content of the file.
45+
* @param fileContent Content of the file.
3746
* @param oldMaterialPrefix Prefix with which the old Material imports should start.
3847
* Has to end with a slash. E.g. if `@import '~@angular/material/theming'` should be
3948
* matched, the prefix would be `~@angular/material/`.
@@ -44,21 +53,22 @@ interface ExtraSymbols {
4453
* @param newCdkImportPath New import to the CDK Sass APIs (e.g. `~@angular/cdk`).
4554
* @param excludedImports Pattern that can be used to exclude imports from being processed.
4655
*/
47-
export function migrateFileContent(content: string,
56+
export function migrateFileContent(fileContent: string,
4857
oldMaterialPrefix: string,
4958
oldCdkPrefix: string,
5059
newMaterialImportPath: string,
5160
newCdkImportPath: string,
5261
extraMaterialSymbols: ExtraSymbols = {},
5362
excludedImports?: RegExp): string {
63+
let {content, placeholders} = escapeComments(fileContent);
5464
const materialResults = detectImports(content, oldMaterialPrefix, excludedImports);
5565
const cdkResults = detectImports(content, oldCdkPrefix, excludedImports);
5666

5767
// Try to migrate the symbols even if there are no imports. This is used
5868
// to cover the case where the Components symbols were used transitively.
59-
content = migrateCdkSymbols(content, newCdkImportPath, cdkResults);
69+
content = migrateCdkSymbols(content, newCdkImportPath, placeholders, cdkResults);
6070
content = migrateMaterialSymbols(
61-
content, newMaterialImportPath, materialResults, extraMaterialSymbols);
71+
content, newMaterialImportPath, materialResults, placeholders, extraMaterialSymbols);
6272
content = replaceRemovedVariables(content, removedMaterialVariables);
6373

6474
// We can assume that the migration has taken care of any Components symbols that were
@@ -73,7 +83,7 @@ export function migrateFileContent(content: string,
7383
content = removeStrings(content, cdkResults.imports);
7484
}
7585

76-
return content;
86+
return restoreComments(content, placeholders);
7787
}
7888

7989
/**
@@ -121,6 +131,7 @@ function detectImports(content: string, prefix: string,
121131
/** Migrates the Material symbols in a file. */
122132
function migrateMaterialSymbols(content: string, importPath: string,
123133
detectedImports: DetectImportResult,
134+
commentPlaceholders: Record<string, string>,
124135
extraMaterialSymbols: ExtraSymbols = {}): string {
125136
const initialContent = content;
126137
const namespace = 'mat';
@@ -142,14 +153,15 @@ function migrateMaterialSymbols(content: string, importPath: string,
142153

143154
if (content !== initialContent) {
144155
// Add an import to the new API only if any of the APIs were being used.
145-
content = insertUseStatement(content, importPath, namespace);
156+
content = insertUseStatement(content, importPath, namespace, commentPlaceholders);
146157
}
147158

148159
return content;
149160
}
150161

151162
/** Migrates the CDK symbols in a file. */
152163
function migrateCdkSymbols(content: string, importPath: string,
164+
commentPlaceholders: Record<string, string>,
153165
detectedImports: DetectImportResult): string {
154166
const initialContent = content;
155167
const namespace = 'cdk';
@@ -165,7 +177,7 @@ function migrateCdkSymbols(content: string, importPath: string,
165177
// Previously the CDK symbols were exposed through `material/theming`, but now we have a
166178
// dedicated entrypoint for the CDK. Only add an import for it if any of the symbols are used.
167179
if (content !== initialContent) {
168-
content = insertUseStatement(content, importPath, namespace);
180+
content = insertUseStatement(content, importPath, namespace, commentPlaceholders);
169181
}
170182

171183
return content;
@@ -203,7 +215,8 @@ function renameSymbols(content: string,
203215
}
204216

205217
/** Inserts an `@use` statement in a string. */
206-
function insertUseStatement(content: string, importPath: string, namespace: string): string {
218+
function insertUseStatement(content: string, importPath: string, namespace: string,
219+
commentPlaceholders: Record<string, string>): string {
207220
// If the content already has the `@use` import, we don't need to add anything.
208221
if (new RegExp(`@use +['"]${importPath}['"]`, 'g').test(content)) {
209222
return content;
@@ -217,9 +230,15 @@ function insertUseStatement(content: string, importPath: string, namespace: stri
217230
let newImportIndex = 0;
218231

219232
// One special case is if the file starts with a license header which we want to preserve on top.
220-
if (content.trim().startsWith('/*')) {
221-
const commentEndIndex = content.indexOf('*/', content.indexOf('/*'));
222-
newImportIndex = content.indexOf('\n', commentEndIndex) + 1;
233+
if (content.trim().startsWith(commentPlaceholderStart)) {
234+
const commentStartIndex = content.indexOf(commentPlaceholderStart);
235+
newImportIndex = content.indexOf(commentPlaceholderEnd, commentStartIndex + 1) +
236+
commentPlaceholderEnd.length;
237+
// If the leading comment doesn't end with a newline,
238+
// we need to insert the import at the next line.
239+
if (!commentPlaceholders[content.slice(commentStartIndex, newImportIndex)].endsWith('\n')) {
240+
newImportIndex = Math.max(newImportIndex, content.indexOf('\n', newImportIndex) + 1);
241+
}
223242
}
224243

225244
return content.slice(0, newImportIndex) + `@use '${importPath}' as ${namespace};\n` +
@@ -327,3 +346,48 @@ function replaceRemovedVariables(content: string, variables: Record<string, stri
327346

328347
return content;
329348
}
349+
350+
351+
/**
352+
* Replaces all of the comments in a Sass file with placeholders and
353+
* returns the list of placeholders so they can be restored later.
354+
*/
355+
function escapeComments(content: string): {content: string, placeholders: Record<string, string>} {
356+
const placeholders: Record<string, string> = {};
357+
let commentCounter = 0;
358+
let [openIndex, closeIndex] = findComment(content);
359+
360+
while (openIndex > -1 && closeIndex > -1) {
361+
const placeholder = commentPlaceholderStart + (commentCounter++) + commentPlaceholderEnd;
362+
placeholders[placeholder] = content.slice(openIndex, closeIndex);
363+
content = content.slice(0, openIndex) + placeholder + content.slice(closeIndex);
364+
[openIndex, closeIndex] = findComment(content);
365+
}
366+
367+
return {content, placeholders};
368+
}
369+
370+
/** Finds the start and end index of a comment in a file. */
371+
function findComment(content: string): [openIndex: number, closeIndex: number] {
372+
// Add an extra new line at the end so that we can correctly capture single-line comments
373+
// at the end of the file. It doesn't really matter that the end index will be out of bounds,
374+
// because `String.prototype.slice` will clamp it to the string length.
375+
content += '\n';
376+
377+
for (const [open, close] of commentPairs.entries()) {
378+
const openIndex = content.indexOf(open);
379+
380+
if (openIndex > -1) {
381+
const closeIndex = content.indexOf(close, openIndex + 1);
382+
return closeIndex > -1 ? [openIndex, closeIndex + close.length] : [-1, -1];
383+
}
384+
}
385+
386+
return [-1, -1];
387+
}
388+
389+
/** Restores the comments that have been escaped by `escapeComments`. */
390+
function restoreComments(content: string, placeholders: Record<string, string>): string {
391+
Object.keys(placeholders).forEach(key => content = content.replace(key, placeholders[key]));
392+
return content;
393+
}

src/material/schematics/ng-update/test-cases/v12/misc/theming-api-v12.spec.ts

Lines changed: 85 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -281,23 +281,43 @@ describe('v12 theming API migration', () => {
281281
]);
282282
});
283283

284-
it('should account for file headers placed aboved the @import statements', async () => {
285-
writeLines(THEME_PATH, [
286-
`/** This is a license. */`,
287-
`@import './foo'`,
288-
`@import '~@angular/material/theming';`,
289-
`@include mat-core();`,
290-
]);
291-
292-
await runMigration();
293-
294-
expect(splitFile(THEME_PATH)).toEqual([
295-
`/** This is a license. */`,
296-
`@use '~@angular/material' as mat;`,
297-
`@import './foo'`,
298-
`@include mat.core();`,
299-
]);
300-
});
284+
it('should account for multi-line comment file headers placed aboved the @import statements',
285+
async () => {
286+
writeLines(THEME_PATH, [
287+
`/** This is a license. */`,
288+
`@import './foo'`,
289+
`@import '~@angular/material/theming';`,
290+
`@include mat-core();`,
291+
]);
292+
293+
await runMigration();
294+
295+
expect(splitFile(THEME_PATH)).toEqual([
296+
`/** This is a license. */`,
297+
`@use '~@angular/material' as mat;`,
298+
`@import './foo'`,
299+
`@include mat.core();`,
300+
]);
301+
});
302+
303+
it('should account for single-line comment file headers placed aboved the @import statements',
304+
async () => {
305+
writeLines(THEME_PATH, [
306+
`// This is a license.`,
307+
`@import './foo'`,
308+
`@import '~@angular/material/theming';`,
309+
`@include mat-core();`,
310+
]);
311+
312+
await runMigration();
313+
314+
expect(splitFile(THEME_PATH)).toEqual([
315+
`// This is a license.`,
316+
`@use '~@angular/material' as mat;`,
317+
`@import './foo'`,
318+
`@include mat.core();`,
319+
]);
320+
});
301321

302322
it('should migrate multiple files within the same project', async () => {
303323
const componentPath = join(PROJECT_PATH, 'components/dialog.scss');
@@ -806,4 +826,52 @@ describe('v12 theming API migration', () => {
806826
`}`,
807827
]);
808828
});
829+
830+
it('should not migrate commented out code', async () => {
831+
writeLines(THEME_PATH, [
832+
`// @import '~@angular/material/theming';`,
833+
'/* @include mat-core(); */',
834+
]);
835+
836+
await runMigration();
837+
838+
expect(splitFile(THEME_PATH)).toEqual([
839+
`// @import '~@angular/material/theming';`,
840+
'/* @include mat-core(); */',
841+
]);
842+
});
843+
844+
it('should not migrate single-line commented code at the end of the file', async () => {
845+
writeLines(THEME_PATH, [
846+
`// @import '~@angular/material/theming';`,
847+
'// @include mat-core();',
848+
'// @include mat-button-theme();',
849+
]);
850+
851+
await runMigration();
852+
853+
expect(splitFile(THEME_PATH)).toEqual([
854+
`// @import '~@angular/material/theming';`,
855+
'// @include mat-core();',
856+
'// @include mat-button-theme();',
857+
]);
858+
});
859+
860+
it('should handle mixed commented and non-commented content', async () => {
861+
writeLines(THEME_PATH, [
862+
`// @import '~@angular/material/theming';`,
863+
'@include mat-core();',
864+
'@include mat-button-theme();',
865+
]);
866+
867+
await runMigration();
868+
869+
expect(splitFile(THEME_PATH)).toEqual([
870+
`// @import '~@angular/material/theming';`,
871+
`@use '~@angular/material' as mat;`,
872+
'@include mat.core();',
873+
'@include mat.button-theme();',
874+
]);
875+
});
876+
809877
});

0 commit comments

Comments
 (0)