blob: a147f5018e6846ec0b5494853bffd2645862eb7f [file] [log] [blame]
// Copyright (C) 2019 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
import {LegacyPolymerComponent} from './polymerComponentParser';
import * as ts from 'typescript';
import * as codeUtils from '../utils/codeUtils';
import * as path from "path";
import * as fs from "fs";
import {LegacyPolymerFuncReplaceResult} from './legacyPolymerFuncReplacer';
import {CommentsParser} from '../utils/commentsParser';
export interface UpdatedFileWriterParameters {
out: string;
inplace: boolean;
writeOutput: boolean;
rootDir: string;
}
interface Replacement {
start: number;
length: number;
newText: string;
}
const elementRegistrationRegex = /^(\s*)customElements.define\((\w+).is, \w+\);$/m;
const maxLineLength = 80;
export class UpdatedFileWriter {
public constructor(private readonly component: LegacyPolymerComponent, private readonly params: UpdatedFileWriterParameters) {
}
public write(replaceResult: LegacyPolymerFuncReplaceResult, eventsComments: string[], generatedComments: string[]) {
const options: ts.PrinterOptions = {
removeComments: false,
newLine: ts.NewLineKind.LineFeed,
};
const printer = ts.createPrinter(options);
let newContent = codeUtils.applyNewLines(printer.printFile(replaceResult.file));
//ts printer doesn't keep original formatting of the file (spacing, new lines, comments, etc...).
//The following code tries restore original formatting
const existingComments = this.collectAllComments(newContent, []);
newContent = this.restoreEventsComments(newContent, eventsComments, existingComments);
newContent = this.restoreLeadingComments(newContent, replaceResult.leadingComments);
newContent = this.restoreFormating(printer, newContent);
newContent = this.splitLongLines(newContent);
newContent = this.addCommentsWarnings(newContent, generatedComments);
if (this.params.writeOutput) {
const outDir = this.params.inplace ? this.params.rootDir : this.params.out;
const fullOutPath = path.resolve(outDir, this.component.jsFile);
const fullOutDir = path.dirname(fullOutPath);
if (!fs.existsSync(fullOutDir)) {
fs.mkdirSync(fullOutDir, {
recursive: true,
mode: fs.lstatSync(this.params.rootDir).mode
});
}
fs.writeFileSync(fullOutPath, newContent);
}
}
private restoreEventsComments(content: string, eventsComments: string[], existingComments: Map<string, number>): string {
//In some cases Typescript compiler keep existing comments. These comments
// must not be restored here
eventsComments = eventsComments.filter(c => !existingComments.has(this.getNormalizedComment(c)));
return codeUtils.replaceComment(content, "eventsComments", "\n" + eventsComments.join("\n\n") + "\n");
}
private restoreLeadingComments(content: string, leadingComments: string[]): string {
return leadingComments.reduce(
(newContent, comment, commentIndex) =>
codeUtils.replaceComment(newContent, String(commentIndex), comment),
content);
}
private restoreFormating(printer: ts.Printer, newContent: string): string {
const originalFile = this.component.parsedFile;
const newFile = ts.createSourceFile(originalFile.fileName, newContent, originalFile.languageVersion, true, ts.ScriptKind.JS);
const textMap = new Map<ts.SyntaxKind, Map<string, Set<string>>>();
const comments = new Set<string>();
this.collectAllStrings(printer, originalFile, textMap);
const replacements: Replacement[] = [];
this.collectReplacements(printer, newFile, textMap, replacements);
replacements.sort((a, b) => b.start - a.start);
let result = newFile.getFullText();
let prevReplacement: Replacement | null = null;
for (const replacement of replacements) {
if (prevReplacement) {
if (replacement.start + replacement.length > prevReplacement.start) {
throw new Error('Internal error! Replacements must not intersect');
}
}
result = result.substring(0, replacement.start) + replacement.newText + result.substring(replacement.start + replacement.length);
prevReplacement = replacement;
}
return result;
}
private splitLongLines(content: string): string {
content = content.replace(elementRegistrationRegex, (match, indent, className) => {
if (match.length > maxLineLength) {
return `${indent}customElements.define(${className}.is,\n` +
`${indent} ${className});`;
}
else {
return match;
}
});
return content
.replace(
"Polymer.LegacyDataMixin(Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element)))",
"Polymer.LegacyDataMixin(\nPolymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element)))")
.replace(
"Polymer.GestureEventListeners(Polymer.LegacyElementMixin(Polymer.Element))",
"Polymer.GestureEventListeners(\nPolymer.LegacyElementMixin(\nPolymer.Element))");
}
private addCommentsWarnings(newContent: string, generatedComments: string[]): string {
const expectedComments = this.collectAllComments(this.component.parsedFile.getFullText(), generatedComments);
const newComments = this.collectAllComments(newContent, []);
const commentsWarnings = [];
for (const [text, count] of expectedComments) {
const newCount = newComments.get(text);
if (!newCount) {
commentsWarnings.push(`Comment '${text}' is missing in the new content.`);
}
else if (newCount != count) {
commentsWarnings.push(`Comment '${text}' appears ${newCount} times in the new file and ${count} times in the old file.`);
}
}
for (const [text, newCount] of newComments) {
if (!expectedComments.has(text)) {
commentsWarnings.push(`Comment '${text}' appears only in the new content`);
}
}
if (commentsWarnings.length === 0) {
return newContent;
}
let commentsProblemStr = "";
if (commentsWarnings.length > 0) {
commentsProblemStr = commentsWarnings.join("-----------------------------\n");
console.log(commentsProblemStr);
}
return "//This file has the following problems with comments:\n" + commentsProblemStr + "\n" + newContent;
}
private collectAllComments(content: string, additionalComments: string[]): Map<string, number> {
const comments = CommentsParser.collectAllComments(content);
comments.push(...additionalComments);
const result = new Map<string, number>();
for (const comment of comments) {
let normalizedComment = this.getNormalizedComment(comment);
const count = result.get(normalizedComment);
if (count) {
result.set(normalizedComment, count + 1);
} else {
result.set(normalizedComment, 1);
}
}
return result;
}
private getNormalizedComment(comment: string): string {
if(comment.startsWith('/**')) {
comment = comment.replace(/^\s+\*/gm, "*");
}
return comment;
}
private collectAllStrings(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>) {
const formattedText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())
const originalText = node.getFullText();
this.addIfNotExists(map, node.kind, formattedText, originalText);
ts.forEachChild(node, child => this.collectAllStrings(printer, child, map));
}
private collectReplacements(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>, replacements: Replacement[]) {
if(node.kind === ts.SyntaxKind.ThisKeyword || node.kind === ts.SyntaxKind.Identifier || node.kind === ts.SyntaxKind.StringLiteral || node.kind === ts.SyntaxKind.NumericLiteral) {
return;
}
const replacement = this.getReplacement(printer, node, map);
if(replacement) {
replacements.push(replacement);
return;
}
ts.forEachChild(node, child => this.collectReplacements(printer, child, map, replacements));
}
private addIfNotExists(map: Map<ts.SyntaxKind, Map<string, Set<string>>>, kind: ts.SyntaxKind, formattedText: string, originalText: string) {
let mapForKind = map.get(kind);
if(!mapForKind) {
mapForKind = new Map();
map.set(kind, mapForKind);
}
let existingOriginalText = mapForKind.get(formattedText);
if(!existingOriginalText) {
existingOriginalText = new Set<string>();
mapForKind.set(formattedText, existingOriginalText);
//throw new Error(`Different formatting of the same string exists. Kind: ${ts.SyntaxKind[kind]}.\nFormatting 1:\n${originalText}\nFormatting2:\n${existingOriginalText}\n `);
}
existingOriginalText.add(originalText);
}
private getReplacement(printer: ts.Printer, node: ts.Node, map: Map<ts.SyntaxKind, Map<string, Set<string>>>): Replacement | undefined {
const replacementsForKind = map.get(node.kind);
if(!replacementsForKind) {
return;
}
// Use printer instead of getFullText to "isolate" node content.
// node.getFullText returns text with indents from the original file.
const newText = printer.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile());
const originalSet = replacementsForKind.get(newText);
if(!originalSet || originalSet.size === 0) {
return;
}
if(originalSet.size >= 2) {
console.log(`Multiple replacements possible. Formatting of some lines can be changed`);
}
const replacementText: string = originalSet.values().next().value;
const nodeText = node.getFullText();
return {
start: node.pos,
length: nodeText.length,//Do not use newText here!
newText: replacementText,
}
}
}