| // Copyright 2023 The ChromiumOS Authors | |
| // Use of this source code is governed by a BSD-style license that can be | |
| // found in the LICENSE file. | |
| import * as path from 'path'; | |
| import {TextDecoder} from 'util'; | |
| import * as vscode from 'vscode'; | |
| import * as config from '../services/config'; | |
| /** | |
| * Inserts boilerplate (such as the copyright header) into newly created files. Whenever a new file | |
| * is created, it queries its list of `BoilerplateGenerator`s to find one that supports creating | |
| * boilerplate for the file. If one is found, then that generator is used to generate the actual | |
| * boilerplate. | |
| * | |
| * Generators are registered using the `addBoilerplateGenerator` method. | |
| */ | |
| export class BoilerplateInserter implements vscode.Disposable { | |
| private readonly subscriptions = [ | |
| vscode.workspace.onDidCreateFiles(e => { | |
| for (const f of e.files) { | |
| void this.handle(f); | |
| } | |
| }), | |
| ]; | |
| dispose(): void { | |
| vscode.Disposable.from(...this.subscriptions).dispose(); | |
| } | |
| private shownError = false; | |
| private readonly boilerplateGenerators = new Set<BoilerplateGenerator>(); | |
| /** | |
| * Adds an additional `BoilerplateGenerator` to the internal list of generators. | |
| * | |
| * @param generator The boilerplate generator to add | |
| * @returns A disposable to remove the generator from the list | |
| */ | |
| addBoilerplateGenerator(generator: BoilerplateGenerator): vscode.Disposable { | |
| this.boilerplateGenerators.add(generator); | |
| return new vscode.Disposable(() => | |
| this.boilerplateGenerators.delete(generator) | |
| ); | |
| } | |
| private async handle(uri: vscode.Uri) { | |
| if (!config.boilerplate.enabled.get()) { | |
| return; | |
| } | |
| const document = await vscode.workspace.openTextDocument(uri); | |
| for (const generator of this.boilerplateGenerators) { | |
| if (generator.supportsDocument(document)) { | |
| await this.insertBoilerplate(document, generator); | |
| return; | |
| } | |
| } | |
| } | |
| private async insertBoilerplate( | |
| document: vscode.TextDocument, | |
| generator: BoilerplateGenerator | |
| ) { | |
| const text = await generator.getBoilerplate(document); | |
| if (text === null) { | |
| return; | |
| } | |
| const edit = new vscode.WorkspaceEdit(); | |
| edit.insert(document.uri, new vscode.Position(0, 0), text); | |
| const success = await vscode.workspace.applyEdit(edit); | |
| if (!success && !this.shownError) { | |
| this.shownError = true; | |
| await vscode.window.showErrorMessage( | |
| `Internal error: failed to add boilerplate for ${document.uri}` | |
| ); | |
| return; | |
| } | |
| } | |
| } | |
| /** | |
| * Abstract base class for all boilerplate generators. Two methods are of interest to subclasses and | |
| * should likely be overwritten: `supportsDocument` and `getCopyrightLines`. | |
| */ | |
| export abstract class BoilerplateGenerator { | |
| /** | |
| * Returns whether or not this generator supports boilerplate generation for the given document. | |
| * This method should be overwritten in subclasses to restrict generators to certain directories. | |
| */ | |
| supportsDocument(document: vscode.TextDocument): boolean { | |
| // The license header may already exist. | |
| if (document.lineCount > this.getCopyrightLines().length) { | |
| return false; | |
| } | |
| return this.getLineCommentPrefix(document) !== null; | |
| } | |
| /** | |
| * Must return an array of strings representing the copyright lines to insert at the top of the | |
| * file. Lines should not include comment symbols ("//" or "#"). | |
| */ | |
| protected abstract getCopyrightLines(): string[]; | |
| async getBoilerplate(document: vscode.TextDocument): Promise<string | null> { | |
| const copyrightHeader = this.getCopyrightHeader(document); | |
| if (copyrightHeader === null) { | |
| return null; | |
| } | |
| return `\ | |
| ${copyrightHeader} | |
| `; | |
| } | |
| protected SLASH_COMMENT_LANGUAGES = new Set([ | |
| 'c', | |
| 'cpp', | |
| 'go', | |
| 'javascript', | |
| 'rust', | |
| 'typescript', | |
| ]); | |
| protected HASH_COMMENT_LANGUAGES = new Set(['gn', 'python', 'shellscript']); | |
| protected SLASH_COMMENT_EXTENSIONS = new Set([ | |
| 'h', | |
| 'cc', | |
| 'nc', | |
| 'go', | |
| 'mm', | |
| 'js', | |
| 'mojom', | |
| 'ts', | |
| 'swift', | |
| ]); | |
| protected HASH_COMMENT_EXTENSIONS = new Set(['py', 'gn', 'gni', 'typemap']); | |
| /** | |
| * Depending on which VSCode extensions a user has installed, VSCode might not recognize all of | |
| * the languages in `SLASH_COMMENT_LANGUAGES` and `HASH_COMMENT_LANGUAGES`. Thus, we fall back to | |
| * file extensions if none of the languages match. | |
| */ | |
| protected getLineCommentPrefix(document: vscode.TextDocument): string | null { | |
| const extension = path.extname(document.fileName).slice(1); | |
| return this.SLASH_COMMENT_LANGUAGES.has(document.languageId) | |
| ? '//' | |
| : this.HASH_COMMENT_LANGUAGES.has(document.languageId) | |
| ? '#' | |
| : this.SLASH_COMMENT_EXTENSIONS.has(extension) | |
| ? '//' | |
| : this.HASH_COMMENT_EXTENSIONS.has(extension) | |
| ? '#' | |
| : null; | |
| } | |
| protected getCopyrightHeader(document: vscode.TextDocument): string | null { | |
| const commentPrefix = this.getLineCommentPrefix(document); | |
| if (commentPrefix === null) { | |
| return null; | |
| } | |
| return this.makeComment(commentPrefix, this.getCopyrightLines()); | |
| } | |
| protected makeComment(commentPrefix: string, lines: string[]): string | null { | |
| return lines.map(line => commentPrefix + ' ' + line).join('\n'); | |
| } | |
| protected isSubPath(root: string, aPath: string): boolean { | |
| return !path.relative(root, aPath).startsWith('..'); | |
| } | |
| } | |
| /** | |
| * A boilerplate generator for ChromiumOS projects. | |
| */ | |
| export class ChromiumOSBoilerplateGenerator extends BoilerplateGenerator { | |
| constructor(private readonly chromiumosRoot: string) { | |
| super(); | |
| } | |
| override supportsDocument(document: vscode.TextDocument): boolean { | |
| return ( | |
| super.supportsDocument(document) && | |
| this.isSubPath(this.chromiumosRoot, document.fileName) | |
| ); | |
| } | |
| protected override getCopyrightLines(): string[] { | |
| return [ | |
| `Copyright ${new Date().getFullYear()} The ChromiumOS Authors`, | |
| 'Use of this source code is governed by a BSD-style license that can be', | |
| 'found in the LICENSE file.', | |
| ]; | |
| } | |
| } | |
| /** | |
| * A boilerplate generator for Chromium projects. | |
| */ | |
| export class ChromiumBoilerplateGenerator extends BoilerplateGenerator { | |
| private NO_COMPILE_LINES = [ | |
| 'This is a "No Compile Test" suite.', | |
| 'https://dev.chromium.org/developers/testing/no-compile-tests', | |
| ]; | |
| private TEST_SUFFIXES = ['_test', '_unittest', '_browsertest']; | |
| constructor(private readonly chromiumSrc: string) { | |
| super(); | |
| } | |
| override supportsDocument(document: vscode.TextDocument): boolean { | |
| return ( | |
| super.supportsDocument(document) && | |
| this.isSubPath(this.chromiumSrc, document.fileName) | |
| ); | |
| } | |
| protected override getCopyrightLines(): string[] { | |
| return [ | |
| `Copyright ${new Date().getFullYear()} The Chromium Authors`, | |
| 'Use of this source code is governed by a BSD-style license that can be', | |
| 'found in the LICENSE file.', | |
| ]; | |
| } | |
| override async getBoilerplate( | |
| document: vscode.TextDocument | |
| ): Promise<string | null> { | |
| const copyrightHeader = this.getCopyrightHeader(document); | |
| if (copyrightHeader === null) { | |
| return null; | |
| } | |
| let boilerplate = copyrightHeader + '\n'; | |
| const relativePath = path.relative(this.chromiumSrc, document.fileName); | |
| const extension = path.extname(relativePath); | |
| const parentDirectoryUri = vscode.Uri.file( | |
| path.dirname(document.uri.fsPath) | |
| ); | |
| if (extension === '.h') { | |
| const nameSpace = await guessNamespace(parentDirectoryUri); | |
| boilerplate += this.boilerplateForCppHeader(relativePath, nameSpace); | |
| } else if (extension === '.cc') { | |
| const nameSpace = await guessNamespace(parentDirectoryUri); | |
| boilerplate += this.boilerplateForCppImplementation( | |
| relativePath, | |
| nameSpace | |
| ); | |
| } else if (extension === '.nc') { | |
| boilerplate += this.boilerplateForNoCompile(relativePath); | |
| } else if (extension === '.mm') { | |
| boilerplate += this.boilerplateForObjCppImplementation(relativePath); | |
| } | |
| return boilerplate; | |
| } | |
| private boilerplateForCppHeader( | |
| relativePath: string, | |
| nameSpace: string | null | |
| ) { | |
| let guard = relativePath.toUpperCase() + '_'; | |
| guard = guard.replace(/[/\\.+]/g, '_'); | |
| return ` | |
| #ifndef ${guard} | |
| #define ${guard} | |
| ${ | |
| nameSpace !== null | |
| ? this.boilerplateForNamespace(relativePath, nameSpace) | |
| : '' | |
| } | |
| #endif // ${guard} | |
| `; | |
| } | |
| private boilerplateForCppImplementation( | |
| relativePath: string, | |
| nameSpace: string | null | |
| ) { | |
| const includePath = | |
| this.normalizeSlashes(this.removeTestSuffix(relativePath)) + '.h'; | |
| return ` | |
| #include "${includePath}" | |
| ${ | |
| nameSpace !== null | |
| ? this.boilerplateForNamespace(relativePath, nameSpace) + '\n' | |
| : '' | |
| }`; | |
| } | |
| private boilerplateForNoCompile(relativePath: string) { | |
| return ( | |
| '\n' + | |
| this.makeComment('//', this.NO_COMPILE_LINES) + | |
| '\n' + | |
| this.boilerplateForCppImplementation(relativePath, null) | |
| ); | |
| } | |
| private boilerplateForObjCppImplementation(relativePath: string) { | |
| const includePath = | |
| this.normalizeSlashes(this.removeTestSuffix(relativePath)) + '.h'; | |
| return ` | |
| #import "${includePath}" | |
| `; | |
| } | |
| private boilerplateForNamespace(relativePath: string, nameSpace: string) { | |
| const isTestFile = this.TEST_SUFFIXES.some(suffix => | |
| path.parse(relativePath).name.endsWith(suffix) | |
| ); | |
| return `\ | |
| namespace ${nameSpace} { | |
| ${isTestFile ? 'namespace {\n' : ''}\ | |
| ${isTestFile ? '} // namespace\n' : ''}\ | |
| } // namespace ${nameSpace}`; | |
| } | |
| private removeTestSuffix(relativePath: string) { | |
| const parts = path.parse(relativePath); | |
| const base = path.join(parts.dir, parts.name); | |
| for (const suffix of this.TEST_SUFFIXES) { | |
| if (base.endsWith(suffix)) { | |
| return base.slice(0, -suffix.length); | |
| } | |
| } | |
| return base; | |
| } | |
| private normalizeSlashes(relativePath: string) { | |
| return relativePath.replace(/\\/g, '/'); | |
| } | |
| } | |
| /** | |
| * Reads all .cc and .h files in the given directory and retrieves the most commonly used namespace | |
| * from them. Will timeout after 500ms in case there are too many files. | |
| */ | |
| async function guessNamespace(directory: vscode.Uri): Promise<string | null> { | |
| if (!config.boilerplate.guessNamespace.get()) { | |
| return null; | |
| } | |
| const startTime = Date.now(); | |
| const namespaces = new Map<string, number>(); | |
| for (const [name, fileType] of await vscode.workspace.fs.readDirectory( | |
| directory | |
| )) { | |
| // Search for at most 500ms to avoid a long delay in folders with many and large files. | |
| if (Date.now() - startTime >= 500) { | |
| break; | |
| } | |
| if (fileType !== vscode.FileType.File) { | |
| continue; | |
| } | |
| if (!name.endsWith('.cc') && !name.endsWith('.h')) { | |
| continue; | |
| } | |
| // Read the file and scan it for `namespace ... {`. | |
| const bytes = await vscode.workspace.fs.readFile( | |
| vscode.Uri.joinPath(directory, name) | |
| ); | |
| const text = new TextDecoder().decode(bytes); | |
| for (const match of text.matchAll(/^namespace (.+) \{$/gm)) { | |
| const nameSpace = match[1]; | |
| namespaces.set(nameSpace, (namespaces.get(nameSpace) ?? 0) + 1); | |
| } | |
| } | |
| let mostCommonNamespace: string | null = null; | |
| for (const [nameSpace, count] of namespaces.entries()) { | |
| if ( | |
| mostCommonNamespace === null || | |
| count > namespaces.get(mostCommonNamespace)! | |
| ) { | |
| mostCommonNamespace = nameSpace; | |
| } | |
| } | |
| return mostCommonNamespace; | |
| } | |
| export const TEST_ONLY = { | |
| guessNamespace, | |
| }; |