Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions projects/igniteui-angular/migrations/common/UpdateChanges.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -645,6 +645,53 @@ $var3: igx-comp-theme(
done();
});

it('should keep the brackets balanced for nested theme functions', done => {
const themeChangesJson: ThemeChanges = {
changes: [
{
name: '$remove-me', remove: true,
owner: 'igx-theme-func',
type: ThemeType.Property
},
{
name: '$replace-me', replaceWith: '$replaced',
owner: 'igx-theme-func',
type: ThemeType.Property
}
]
};
const jsonPath = path.join(__dirname, 'changes', 'theme-changes.json');
spyOn(fs, 'existsSync').and.callFake((filePath: fs.PathLike) => filePath === jsonPath);
spyOn<any>(fs, 'readFileSync').and.callFake(() => JSON.stringify(themeChangesJson));

appTree.create('styles.scss',
`@include igx-mixin(igx-theme-func($remove-me: 6px));
@include igx-mixin(igx-theme-func($prop1: red, $remove-me: 6px));
@include igx-mixin(igx-theme-func($remove-me: 6px, $prop1: red));
@include igx-mixin(igx-theme-func($replace-me: 6px));
$var: igx-theme-func($content: "not a ( bracket", $remove-me: 6px);
$var2: igx-theme-func($image: url(https://example.com/a.png), $remove-me: 6px);
$var3: igx-theme-func(
$remove-me: 6px, // not a ) bracket
$prop1: red
);`);

const update = new UnitUpdateChanges(__dirname, appTree);
update.applyChanges();

expect(appTree.readContent('styles.scss')).toEqual(
`@include igx-mixin(igx-theme-func());
@include igx-mixin(igx-theme-func($prop1: red));
@include igx-mixin(igx-theme-func( $prop1: red));
@include igx-mixin(igx-theme-func($replaced: 6px));
$var: igx-theme-func($content: "not a ( bracket");
$var2: igx-theme-func($image: url(https://example.com/a.png));
$var3: igx-theme-func( // not a ) bracket
$prop1: red
);`);
done();
});

it('should replace imports', done => {
const importsJson: ImportsChanges = {
changes: [
Expand Down
158 changes: 123 additions & 35 deletions projects/igniteui-angular/migrations/common/UpdateChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,51 +397,135 @@
if (change.type !== ThemeType.Property) {
continue;
}
if (fileContent.indexOf(change.owner) !== -1) {
/** owner-func:( * ); */
const searchPattern = String.raw`${change.owner}\([\s\S]+?\);`;
const matches = fileContent.match(new RegExp(searchPattern, 'g'));
if (!matches) {
if (fileContent.indexOf(change.owner) === -1) {
continue;
}
/** owner-func:( * ) */
const calls = this.findFunctionCalls(fileContent, change.owner);
// rewrite back to front so the collected indices stay valid
for (const call of calls.reverse()) {
const rawBody = fileContent.substring(call.bodyStart, call.bodyEnd);
if (rawBody.indexOf(change.name) === -1) {
continue;
}
for (const match of matches) {
if (match.indexOf(change.name) !== -1) {
const name = change.name.replace('$', '\\$');
const replaceWith = change.replaceWith?.replace('$', '\\$');
const reg = new RegExp(String.raw`^\s*${name}:`);
const existing = new RegExp(String.raw`${replaceWith}:`);
const opening = `${change.owner}(`;
const closing = /\s*\);$/.exec(match).pop();
const body = match.substr(opening.length, match.length - opening.length - closing.length);

let params = this.splitFunctionProps(body);
params = params.reduce((arr, param) => {
if (reg.test(param)) {
const duplicate = !!replaceWith && arr.some(p => existing.test(p));

if (!change.remove && !duplicate) {
arr.push(param.replace(change.name, change.replaceWith));
}
} else {
arr.push(param);
}
return arr;
}, []);

fileContent = fileContent.replace(
match,
opening + params.join(',') + closing
);
overwrite = true;
// `$` is a regex anchor, escape every one of them before interpolating
const name = change.name.replace(/\$/g, '\\$');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const replaceWith = change.replaceWith?.replace(/\$/g, '\\$');
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
const reg = new RegExp(String.raw`^\s*${name}:`);
const existing = new RegExp(String.raw`${replaceWith}:`);
// keep whatever sits in front of the closing bracket so the formatting is preserved
const trailing = /\s*$/.exec(rawBody).pop();
const body = rawBody.substring(0, rawBody.length - trailing.length);

let params = this.splitFunctionProps(body);
params = params.reduce((arr, param) => {
if (reg.test(param)) {
const duplicate = !!replaceWith && arr.some(p => existing.test(p));

if (!change.remove && !duplicate) {
arr.push(param.replace(change.name, change.replaceWith));
}
} else {
arr.push(param);
}
}
return arr;
}, []);

fileContent = fileContent.substring(0, call.bodyStart)
+ params.join(',')
+ trailing
+ fileContent.substring(call.bodyEnd);
overwrite = true;
}
}
if (overwrite) {
this.host.overwrite(entryPath, fileContent);
}
}

/**
* Returns the argument list boundaries of every top-level `owner(...)` call in the content.
* The brackets are tracked, so a call nested in another one -
* `@include scrollbar(scrollbar-theme($sb-size: 6px))` - reports its own closing bracket
* rather than the one of the call surrounding it.
*/
private findFunctionCalls(content: string, owner: string): { bodyStart: number; bodyEnd: number }[] {
const calls: { bodyStart: number; bodyEnd: number }[] = [];
const opening = `${owner}(`;
let index = content.indexOf(opening);
Comment thread
ChronosSF marked this conversation as resolved.
Outdated

while (index !== -1) {
const bodyStart = index + opening.length;
const bodyEnd = this.findClosingBracket(content, bodyStart);
if (bodyEnd === -1) {
// unbalanced content, nothing safe left to rewrite
break;
}
calls.push({ bodyStart, bodyEnd });
// a same-owner call nested in this one is already covered by it
index = content.indexOf(opening, bodyEnd);
}

return calls;
}

/**
* Returns the index of the bracket closing the one `start` is inside of, or -1 when the
* content is unbalanced. Brackets in strings and comments are ignored.
*/
private findClosingBracket(content: string, start: number): number {
let level = 0;

for (let i = start; i < content.length; i++) {
const char = content[i];
const next = content[i + 1];

if (char === '\'' || char === '"') {
i = this.skipString(content, i);
} else if (char === '/' && next === '*') {
const end = content.indexOf('*/', i + 2);
i = end === -1 ? content.length : end + 1;
} else if (char === '/' && next === '/' && this.isLineCommentStart(content, i)) {
const end = content.indexOf('\n', i + 2);
i = end === -1 ? content.length : end;
} else if (char === '(') {
level++;
} else if (char === ')') {
if (!level) {
return i;
}
level--;
}
}

return -1;
}

/**
* Tells apart a `//` line comment from the `//` of a protocol - `url(https://...)` -
* by looking at what precedes it.
*/
private isLineCommentStart(content: string, index: number): boolean {
const previous = content[index - 1];

return previous === undefined || /[\s,(;{]/.test(previous);
}

/** Returns the index of the quote closing the string opened at `start`. */
private skipString(content: string, start: number): number {
const quote = content[start];

for (let i = start + 1; i < content.length; i++) {
if (content[i] === '\\') {
i++;
} else if (content[i] === quote) {
return i;
}
}

return content.length;
}

protected isNamedArgument(fileContent: string, i: number, occurrences: number[], change: ThemeChange) {
const openingBrackets = [];
const closingBrackets = [];
Expand Down Expand Up @@ -879,6 +963,10 @@

for (let i = 0; i < body.length; i++) {
const char = body[i];
if (char === '\'' || char === '"') {
i = this.skipString(body, i);
continue;
}
switch (char) {
case '(': level++; break;
case ')': level--; break;
Expand Down
43 changes: 43 additions & 0 deletions projects/igniteui-angular/migrations/update-22_2_0/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,49 @@ describe(`Update to ${version}`, () => {
expect(tree.readContent('/testSrc/appPrefix/component/test.component.scss')).toEqual(content);
});

it('should keep the brackets balanced when the theme is nested in another call', async () => {
appTree.create(
`/testSrc/appPrefix/component/test.component.scss`,
`.selection-area {
@include scrollbar(scrollbar-theme($sb-size: 6px));
}

igx-grid {
@include scrollbar(scrollbar-theme($sb-thumb-bg-color: blue, $sb-size: 16px));
}`
);

const tree = await schematicRunner.runSchematic(migrationName, { shouldInvokeLS: false }, appTree);

expect(tree.readContent('/testSrc/appPrefix/component/test.component.scss')).toEqual(
`.selection-area {
@include scrollbar(scrollbar-theme());
}

igx-grid {
@include scrollbar(scrollbar-theme($sb-thumb-bg-color: blue));
}`
);
});

it('should migrate a theme call that is not terminated by a semicolon', async () => {
appTree.create(
`/testSrc/appPrefix/component/test.component.scss`,
`$my-scrollbar: scrollbar-theme(
$sb-size: 16px,
$sb-thumb-bg-color: blue
)`
);

const tree = await schematicRunner.runSchematic(migrationName, { shouldInvokeLS: false }, appTree);

expect(tree.readContent('/testSrc/appPrefix/component/test.component.scss')).toEqual(
`$my-scrollbar: scrollbar-theme(
$sb-thumb-bg-color: blue
)`
);
});

it('should not touch same-named properties on other themes', async () => {
const content = `$my-grid: grid-theme(
$sb-size: 16px
Expand Down
Loading