diff --git a/.editorconfig b/.editorconfig index b0f9a34..aa9b6cc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -13,6 +13,7 @@ indent_size = 4 [*.md] trim_trailing_whitespace = false +indent_style = space [*.yml] indent_style = space diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b78b59..c1bd5cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to this extension will be documented in this file. This Changelog uses the [Keep a Changelog](http://keepachangelog.com/) structure. +## [1.1.10](https://github.com/yCodeTech/auto-comment-blocks/releases/tag/v1.1.10) - 2025-07-21 + +#### Fixed: + +- Fixes yCodeTech/auto-comment-blocks#6 and indirectly yCodeTech/auto-comment-blocks#7 + + VS Code's `vscode.extensions.all` API doesn't find any built-in extensions on the Windows-side when running in WSL. It only gets the WSL-installed extensions, which causes the extension not to work at all because the language configuration file is not found. + + The workaround fix is to manually read the Windows extensions directories when running in WSL and merge them with the WSL-installed extensions. + +- Fixed language support detection to properly respect disabled language settings. + + Custom language configurations now correctly check if a language is disabled before applying support, preventing unwanted language activation. + +- Fixed support for languages with multiple extension configuration files by merging their configurations instead of overwriting them, ensuring complete language support. Also improved language configuration merging by properly handling comment configurations. + +#### Added: + +- Added the ability to get the all extensions directly from the directory on non-WSL systems (eg. Windows) because VS Code's `extensions.all` API only gets enabled extensions and doesn't include disabled ones, which could prevent language configs being found. + + - Removed `vscode.extensions.all` API call from `findAllLanguageConfigFilePaths` method in favour of getting the extensions directly from the directories. + +- Added new dependencies: + + - `is-wsl` for detecting WSL environments. + - `package-json-type` for TypeScript type definitions of package.json. + +- Added macOS keybinding support (`cmd+shift+m`) for the Blade override comments command. + +#### Changed: + +- Refactored extension architecture with improved separation of concerns. + + Major code reorganisation including extraction of utility functions, centralised extension data management, and improved debugging capabilities. + + - Added new `ExtensionData` class to centralize extension metadata management. + + This new class provides a clean interface for accessing extension details like ID, name, version, and various system paths, improving code organisation and maintainability. + + - Added new `utils.ts` file with shared utility functions. + + Extracted common functionality into reusable utility functions including JSON file operations, regex reconstruction, array merging, and data conversion utilities. + +- Updated debug logging to provide more comprehensive environment and configuration information. Enhanced diagnostic output now includes detailed extension paths for both WSL and native environments, making troubleshooting easier. + ## [1.1.9](https://github.com/yCodeTech/auto-comment-blocks/releases/tag/v1.1.9) - 2025-07-12 #### Fixed: diff --git a/README.md b/README.md index 03e0a91..239c60a 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,12 @@ There are 3 conditions in which a language is officially supported: 2. The language is not defined in the `skip-languages` config file; and 3. The language config has either `lineComment` or `blockComment` keys defined. -Most of the officially VScode-supported languages (as defined in the [docs](https://code.visualstudio.com/docs/languages/identifiers#_known-language-identifiers)) pass these conditions. +Most of the officially VScode default languages (as defined in the [docs](https://code.visualstudio.com/docs/languages/identifiers#_known-language-identifiers)) pass these conditions. + +For a full list of auto-supported VScode default languages, please view the auto-generated definition files: + +- [Multi-line languages](https://github.com/yCodeTech/auto-comment-blocks/blob/master/auto-generated-language-definitions/multi-line-languages.json) +- [Single-line languages](https://github.com/yCodeTech/auto-comment-blocks/blob/master/auto-generated-language-definitions/single-line-languages.json) --- @@ -124,7 +129,7 @@ Use `/*!` in all file types that support the normal `/*` comments to start a QDo #### Normal comment blocks -Using the normal comment block `/* */` either typing manually or the native VScode command "Toggle Block Comment" (`editor.action.blockComment`, native keybinding `shift + alt + a`), the block will have the same on enter functionality as described above. +Using the normal comment block `/* */` either typing manually or the native VScode command "Toggle Block Comment" (`editor.action.blockComment`, native keybinding shift + alt + a (macOS: shift + option + a)), the block will have the same on enter functionality as described above. ![block-comments](https://raw.githubusercontent.com/yCodeTech/auto-comment-blocks/master/img/block-comments.gif) @@ -156,9 +161,9 @@ Reload the extension after changing any settings. - `auto-comment-blocks.multiLineStyleBlocks`: Add language IDs here to enable multi-line comment blocks support for that language, allowing unsupported languages to have comment completion. The default is `['blade', 'html']`" -- `auto-comment-blocks.overrideDefaultLanguageMultiLineComments`: A key : value pairing of language IDs and the beginning portion of a multi-line comment style, to override the default comment style for the vscode "Toggle Block Comment" `editor.action.blockComment` command (native Keybinding `shift + alt + a`). eg. `{'php': '/*!'}` +- `auto-comment-blocks.overrideDefaultLanguageMultiLineComments`: A key : value pairing of language IDs and the beginning portion of a multi-line comment style, to override the default comment style for the vscode "Toggle Block Comment" `editor.action.blockComment` command (native Keybinding shift + alt + a (macOS: shift + option + a)). eg. `{'php': '/*!'}` -- `auto-comment-blocks.bladeOverrideComments`: When enabled, Blade-style block comments will be used in Blade contexts. Ie. `{{-- --}}` comments will be used instead of the HTML `` comments. Keybinding to enable/disable, default `ctrl + shift + m`. If `blade` language ID is set in the disabledLanguages, then the HTML `` comments will be used. +- `auto-comment-blocks.bladeOverrideComments`: When enabled, Blade-style block comments will be used in Blade contexts. Ie. `{{-- --}}` comments will be used instead of the HTML `` comments. Keybinding to enable/disable, default ctrl + shift + m (macOS: cmd + shift + m). If `blade` language ID is set in the disabledLanguages, then the HTML `` comments will be used. ## Known Issues @@ -166,8 +171,6 @@ Reload the extension after changing any settings. - Currently, VS Code only allows extensions to overwrite, instead of modify, existing language configurations. This means that this extension may clash with another extension that overwrites the same language configurations, causing one or both not to work. In that case, uninstalling this extension is the only option for now. -- Doesn't work properly on Windows Linux WSL2. VScode API only finds language configs that are installed only on WSL2, and not also on Windows. That means all the normal built-in as well as 3rd party extensions won't have auto comment blocks support in WSL2. (Related issue: [#6](https://github.com/yCodeTech/auto-comment-blocks/issues/6)) - Please [report an issue](https://github.com/yCodeTech/auto-comment-blocks/issues/new) if you find any bugs, or have questions or feature requests. As of v1.1.7, debugging information is now logged to a dedicated `Auto Comment Blocks` Output channel. Please save the entire log to file using the `Save Output As` button in the Output's "3-dot menu", and attach the file to any new issue. diff --git a/auto-generated-language-definitions/multi-line-languages.json b/auto-generated-language-definitions/multi-line-languages.json index 1e690d9..4413b4f 100644 --- a/auto-generated-language-definitions/multi-line-languages.json +++ b/auto-generated-language-definitions/multi-line-languages.json @@ -30,8 +30,5 @@ "typescript", "typescriptreact" ], - "customSupportedLanguages": [ - "blade", - "html" - ] + "customSupportedLanguages": [] } \ No newline at end of file diff --git a/auto-generated-language-definitions/single-line-languages.json b/auto-generated-language-definitions/single-line-languages.json index 9f6a72c..fc99f85 100644 --- a/auto-generated-language-definitions/single-line-languages.json +++ b/auto-generated-language-definitions/single-line-languages.json @@ -11,7 +11,6 @@ "git-commit", "git-rebase", "github-actions-workflow", - "http", "ignore", "julia", "makefile", @@ -58,7 +57,8 @@ ], ";": [ "clojure", - "ini" + "ini", + "wat" ] }, "customSupportedLanguages": {} diff --git a/package.json b/package.json index f3c75be..8db967f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "automatic-comment-blocks", "displayName": "Automatic Comment Blocks", "description": "Provides block comment completion for Javadoc-style multi-line comments and single-line comment blocks for most officially supported languages.", - "version": "1.1.9", + "version": "1.1.10", "publisher": "ycodetech", "homepage": "https://github.com/ycodetech/auto-comment-blocks", "repository": { @@ -71,13 +71,13 @@ "auto-comment-blocks.overrideDefaultLanguageMultiLineComments": { "type": "object", "default": {}, - "markdownDescription": "A key : value pairing of language IDs and the beginning portion of a multi-line comment style, to override the default comment style for the vscode `command editor.action.blockComment` (native Keybinding `shift + alt + a`). eg. `{'php': '/*!'}`" + "markdownDescription": "A key : value pairing of language IDs and the beginning portion of a multi-line comment style, to override the default comment style for the vscode `command editor.action.blockComment` (native Keybinding `shift + alt + a` (macOS: `shift + option + a`)). eg. `{'php': '/*!'}`" }, "auto-comment-blocks.bladeOverrideComments": { "scope": "resource", "type": "boolean", "default": false, - "markdownDescription": "When enabled, Blade style block comments will be used in Blade contexts. Ie. `{{-- --}}` comments will be used instead of the HTML `` comments. Keybinding to enable/disable, default `ctrl + shift + m`. If `blade` language ID is set in the disabledLanguages, then the HTML `` comments will be used." + "markdownDescription": "When enabled, Blade style block comments will be used in Blade contexts. Ie. `{{-- --}}` comments will be used instead of the HTML `` comments. Keybinding to enable/disable, default `ctrl + shift + m` (macOS: `cmd + shift + m`). If `blade` language ID is set in the disabledLanguages, then the HTML `` comments will be used." } } }, @@ -90,6 +90,7 @@ { "command": "auto-comment-blocks.changeBladeMultiLineBlock", "key": "ctrl+shift+m", + "mac": "cmd+shift+m", "when": "editorTextFocus" } ] @@ -108,6 +109,8 @@ "typescript": "^5.7" }, "dependencies": { - "jsonc-parser": "^3.3.1" + "is-wsl": "^3.1.0", + "jsonc-parser": "^3.3.1", + "package-json-type": "^1.0.3" } } diff --git a/src/configuration.ts b/src/configuration.ts index 9f4ab51..7437c35 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -3,11 +3,14 @@ import * as vscode from "vscode"; import * as fs from "node:fs"; -import * as jsonc from "jsonc-parser"; import * as path from "path"; +import isWsl from "is-wsl"; +import {IPackageJson} from "package-json-type"; import {Rules} from "./rules"; import {Logger} from "./logger"; +import * as utils from "./utils"; +import {ExtensionData} from "./extensionData"; export class Configuration { /************** @@ -21,10 +24,16 @@ export class Configuration { */ private logger: Logger; + /** + * This extension data class instance. + * @type {ExtensionData} + */ + private extensionData: ExtensionData = new ExtensionData(); + /** * A key:value Map object of language IDs and their config file paths. */ - private languageConfigFilePaths = new Map(); + private languageConfigFilePaths = new Map(); /** * A key:value Map object of language IDs and their configs. @@ -47,10 +56,22 @@ export class Configuration { */ private multiLineBlocksMap: Map = new Map(); + /** + * The directory where the auto-generated language definitions are stored. + * @type {string} + */ private readonly autoGeneratedDir = `${__dirname}/../../auto-generated-language-definitions`; + /** + * The file path for the single-line language definitions. + * @type {string} + */ private readonly singleLineLangDefinitionFilePath = `${this.autoGeneratedDir}/single-line-languages.json`; + /** + * The file path for the multi-line language definitions. + * @type {string} + */ private readonly multiLineLangDefinitionFilePath = `${this.autoGeneratedDir}/multi-line-languages.json`; /*********** @@ -61,24 +82,7 @@ export class Configuration { this.logger = logger; // Always output extension information to channel on activate. - const extensionId = this.getExtensionNames().id; - const extensionVersion = vscode.extensions.getExtension(extensionId)?.packageJSON.version; - this.logger.info(`Extension: ${extensionId} (${extensionVersion})`); - - const env = { - "OS": process.platform, - "Platform": process.platform, - "VS Code Version": vscode.version, - "VS Code Root Path": vscode.env.appRoot, - "VS Code Built-in Extensions Path": `${vscode.env.appRoot}\\extensions`, - "VS Code Host": vscode.env.appHost, - "VS Code Remote Name": vscode.env.remoteName || "local", - "Other System Env Variables": process.env, - }; - this.logger.debug("Environment:", env); - - // Log the extension's user configuration settings. - this.logger.debug("Configuration settings:", this.getConfiguration()); + this.logger.debug(`Extension details:`, this.extensionData.getAll()); this.findAllLanguageConfigFilePaths(); this.setLanguageConfigDefinitions(); @@ -87,20 +91,15 @@ export class Configuration { this.setSingleLineCommentLanguageDefinitions(); this.writeCommentLanguageDefinitionsToJsonFile(); - // Log the objects for debugging purposes. - this.logger.debug("The language config filepaths found are:", this.languageConfigFilePaths); - this.logger.debug("The language configs found are:", this.languageConfigs); - this.logger.debug("The supported languages for multi-line blocks:", this.multiLineBlocksMap); - this.logger.debug("The supported languages single-line blocks:", this.singleLineBlocksMap); + this.logDebugInfo(); } /** * Configure the comment blocks. * - * @param {vscode.ExtensionContext} context The context of the extension. * @returns {vscode.Disposable[]} */ - public configureCommentBlocks(context: vscode.ExtensionContext) { + public configureCommentBlocks() { const disposables: vscode.Disposable[] = []; /** @@ -112,6 +111,7 @@ export class Configuration { // Setup the auto-supported single-line languages. for (let [langId, style] of singleLineLangs) { + // If langId isn't disabled... if (!this.isLangIdDisabled(langId)) { // Set a bool if the single-line language also supports multi-line comments // (ie. the single-line language is also present in the multi-line map); @@ -138,16 +138,20 @@ export class Configuration { // Setup the custom-supported single-line languages, that are otherwise unsupported. for (let [langId, style] of customSingleLineLangs) { - // Set a bool if the single-line language also supports multi-line comments - // (ie. the single-line language is also present in the multi-line map); - let multiLine = customMultiLineLangs.includes(langId); - disposables.push(this.setLanguageConfiguration(langId, multiLine, style)); + // If the langId isn't set as disabled... + if (!this.isLangIdDisabled(langId)) { + // Set a bool if the single-line language also supports multi-line comments + // (ie. the single-line language is also present in the multi-line map); + let multiLine = customMultiLineLangs.includes(langId); + disposables.push(this.setLanguageConfiguration(langId, multiLine, style)); + } } // Setup the custom-supported multi-line languages, that are otherwise unsupported. for (let langId of customMultiLineLangs) { - // If customSingleLineLangs doesn't have the langId - if (!customSingleLineLangs.has(langId)) { + // If customSingleLineLangs doesn't have the langId AND + // the langId isn't set as disabled... + if (!customSingleLineLangs.has(langId) && !this.isLangIdDisabled(langId)) { disposables.push(this.setLanguageConfiguration(langId, true)); } } @@ -211,32 +215,13 @@ export class Configuration { } } - /** - * Get the names and ids of this extension from package.json. - * - * @returns {object} An object containing the extension id, name, and display name. - */ - public getExtensionNames(): {id: string; name: string; displayName: string} { - const packageJSON = JSON.parse(fs.readFileSync(__dirname + "/../../package.json").toString()); - - const displayName: string = packageJSON.displayName; - const fullname: string = packageJSON.name; - const id: string = `${packageJSON.publisher}.${fullname}`; - - let nameParts = fullname.split("-"); - nameParts[0] = "auto"; - const name = nameParts.join("-"); - - return {id: id, name: name, displayName: displayName}; - } - /** * Get all the extension's configuration settings. * * @returns {vscode.WorkspaceConfiguration} */ public getConfiguration(): vscode.WorkspaceConfiguration { - return vscode.workspace.getConfiguration(this.getExtensionNames().name, null); + return vscode.workspace.getConfiguration(this.extensionData.get("name"), null); } /** @@ -313,7 +298,7 @@ export class Configuration { * @returns {string[]} */ private getLanguagesToSkip(): string[] { - const json = this.readJsonFile(`${__dirname}/../../config/skip-languages.jsonc`); + const json = utils.readJsonFile(`${__dirname}/../../config/skip-languages.jsonc`); return json.languages; } @@ -322,32 +307,69 @@ export class Configuration { * (built-in and 3rd party). */ private findAllLanguageConfigFilePaths() { + const extensions: any[] = []; + + // If running in WSL... + if (isWsl) { + // Get the Windows user and built-in extensions paths. + const windowsUserExtensionsPath = this.extensionData.get("WindowsUserExtensionsPathFromWsl"); + const windowsBuiltInExtensionsPath = this.extensionData.get("WindowsBuiltInExtensionsPathFromWsl"); + + // Read the paths and create arrays of the extensions. + const windowsBuiltInExtensions = this.readExtensionsFromDirectory(windowsBuiltInExtensionsPath); + const windowsUserExtensions = this.readExtensionsFromDirectory(windowsUserExtensionsPath); + + // Combine the built-in and user extensions into the extensions array. + extensions.push(...windowsBuiltInExtensions, ...windowsUserExtensions); + } + + const userExtensionsPath = this.extensionData.get("userExtensionsPath"); + const builtInExtensionsPath = this.extensionData.get("builtInExtensionsPath"); + + // Read the paths and create arrays of the extensions. + const userExtensions = this.readExtensionsFromDirectory(userExtensionsPath); + const builtInExtensions = this.readExtensionsFromDirectory(builtInExtensionsPath); + + // Add all installed extensions (including built-in ones) into the extensions array. + // If running WSL, these will be the WSL-installed extensions. + extensions.push(...builtInExtensions, ...userExtensions); + // Loop through all installed extensions, including built-in extensions - for (let extension of vscode.extensions.all) { - const packageJSON = extension.packageJSON; + for (let extension of extensions) { + const packageJSON: IPackageJson = extension.packageJSON; // If an extension package.json has "contributes" key, // AND the contributes object has "languages" key... if (Object.hasOwn(packageJSON, "contributes") && Object.hasOwn(packageJSON.contributes, "languages")) { // Loop through the languages... for (let language of packageJSON.contributes.languages) { + const langId = language.id; // Get the languages to skip. let skipLangs = this.getLanguagesToSkip(); - // If skipLangs doesn't include the language ID, + // If skipLangs doesn't include the langId, // AND the language object has "configuration" key... - if (!skipLangs?.includes(language.id) && Object.hasOwn(language, "configuration")) { + if (!skipLangs?.includes(langId) && Object.hasOwn(language, "configuration")) { // Join the extension path with the configuration path. let configPath = path.join(extension.extensionPath, language.configuration); - // Set the language ID and config path into the languageConfigFilePaths Map. - this.languageConfigFilePaths.set(language.id, configPath); + + // If the langId already exists... + if (this.languageConfigFilePaths.has(langId)) { + // Push the new config path into the array of the existing langId. + this.languageConfigFilePaths.get(langId).push(configPath); + } + // Otherwise, if the langId doesn't exist... + else { + // Set the langId with a new config path array. + this.languageConfigFilePaths.set(langId, [configPath]); + } } } } } // Set the languageConfigFilePaths to a new map with all the languages sorted in - // ascending order,for sanity reasons. + // ascending order, for sanity reasons. this.languageConfigFilePaths = new Map([...this.languageConfigFilePaths].sort()); } @@ -355,46 +377,83 @@ export class Configuration { * Set the language config definitions. */ private setLanguageConfigDefinitions() { - this.languageConfigFilePaths.forEach((filepath, langId) => { - const config = this.readJsonFile(filepath); - - // If the config JSON has more than 0 keys (ie. not empty) - if (Object.keys(config).length > 0) { - /** - * Change all autoClosingPairs items that are using the simpler syntax - * (array instead of object) into the object with open and close keys. - * Prevents vscode from failing quietly and not changing the editor language - * properly, which makes the open file become unresponsive when changing tabs. - */ - - // If config has key autoClosingPairs... - if (Object.hasOwn(config, "autoClosingPairs")) { - // Define a new array as the new AutoClosingPair. - const autoClosingPairsArray: vscode.AutoClosingPair[] = []; - // Loop through the config's autoClosingPairs... - config.autoClosingPairs.forEach((item) => { - // If the item is an array... - if (Array.isArray(item)) { - // Create a new object with the 1st array element [0] as the - // value of the open key, and the 2nd element [1] as the value - // of the close key. - const autoClosingPairsObj = {open: item[0], close: item[1]}; - // Push the object into the new array. - autoClosingPairsArray.push(autoClosingPairsObj); + this.languageConfigFilePaths.forEach((paths, langId) => { + // Loop through the paths array... + paths.forEach((filepath) => { + let config = utils.readJsonFile(filepath); + + // If the config JSON has more than 0 keys (ie. not empty) + if (Object.keys(config).length > 0) { + /** + * Change all autoClosingPairs items that are using the simpler syntax + * (array instead of object) into the object with open and close keys. + * Prevents vscode from failing quietly and not changing the editor language + * properly, which makes the open file become unresponsive when changing tabs. + */ + + // If config has key autoClosingPairs... + if (Object.hasOwn(config, "autoClosingPairs")) { + // Define a new array as the new AutoClosingPair. + const autoClosingPairsArray: vscode.AutoClosingPair[] = []; + // Loop through the config's autoClosingPairs... + config.autoClosingPairs.forEach((item) => { + // If the item is an array... + if (Array.isArray(item)) { + // Create a new object with the 1st array element [0] as the + // value of the open key, and the 2nd element [1] as the value + // of the close key. + const autoClosingPairsObj = {open: item[0], close: item[1]}; + // Push the object into the new array. + autoClosingPairsArray.push(autoClosingPairsObj); + } + // Otherwise, the item is an object, so just push it into the array. + else { + autoClosingPairsArray.push(item); + } + }); + + // Add the new array to the config's autoClosingPairs key. + config.autoClosingPairs = autoClosingPairsArray; + } + + // If the langId already exists, then it has multiple config files from + // different extensions, so we need to merge them together to ensure + // a full configuration is set, to avoid issues of missing values. + if (this.languageConfigs.has(langId)) { + const existingConfig = this.languageConfigs.get(langId); + + // Only merge if both configs have comments + if (existingConfig.comments && config.comments) { + // Start with existing comments as base + const mergedComments = {...existingConfig.comments}; + + // Merge each comment type from new config. + Object.entries(config.comments).forEach(([key, value]) => { + // Skip empty arrays. + if (Array.isArray(value) && value.length === 0) { + return; + } + mergedComments[key] = value; + }); + + // Update the config with merged comments + config = { + ...existingConfig, + ...config, + comments: mergedComments, + }; } - // Otherwise, the item is an object, so just push it into the array. + // If only one config has comments or neither has comments... else { - autoClosingPairsArray.push(item); + // Just merge the configs directly. + config = {...existingConfig, ...config}; } - }); + } - // Add the new array to the config's autoClosingPairs key. - config.autoClosingPairs = autoClosingPairsArray; + // Set the language configs into the Map. + this.languageConfigs.set(langId, config); } - - // Set the language configs into the Map. - this.languageConfigs.set(langId, config); - } + }); }); } @@ -415,32 +474,43 @@ export class Configuration { } /** - * Read the file and parse the JSON. + * Read the directory in the given path and return an array of objects with the data of + * all extensions found in the directory. * - * @param {string} filepath The path of the file. - * @returns The file content. + * @param {string} extensionsPath The path where extensions are stored. + * + * @returns {Array<{ id: string; extensionPath: string; packageJSON: IPackageJson }>} */ - private readJsonFile(filepath: string): any { - return jsonc.parse(fs.readFileSync(filepath).toString()); - } + private readExtensionsFromDirectory(extensionsPath: string): Array<{id: string; extensionPath: string; packageJSON: IPackageJson}> { + // Create an array to hold the found extensions. + const foundExtensions: Array<{id: string; extensionPath: string; packageJSON: IPackageJson}> = []; - /** - * Read the file and parse the JSON. - * - * @param {string} filepath The path of the file. - * @param {any} data The data to write into the file. - * @returns The file content. - */ - private writeJsonFile(filepath: string, data: any): any { - // Check if the "auto-generated-language-definitions" directory exists, - // and create it if it doesn't. - if (!fs.existsSync(this.autoGeneratedDir)) { - fs.mkdirSync(this.autoGeneratedDir); - } + fs.readdirSync(extensionsPath).forEach((extensionName) => { + const extensionPath = path.join(extensionsPath, extensionName); + + // If the extensionName is a directory... + if (fs.statSync(extensionPath).isDirectory()) { + // If the extensionName starts with a dot, skip it. + if (extensionName.startsWith(".")) { + return; + } + + // Get the package.json file path. + const packageJSONPath = path.join(extensionPath, "package.json"); - // Write the updated JSON back into the file and add tab indentation - // to make it easier to read. - fs.writeFileSync(filepath, JSON.stringify(data, null, "\t")); + // If the package.json file exists... + if (fs.existsSync(packageJSONPath)) { + const packageJSON: IPackageJson = utils.readJsonFile(packageJSONPath); + + const id = `${packageJSON.publisher}.${packageJSON.name}`; + + // Push the extension data object into the array. + foundExtensions.push({id, extensionPath, packageJSON}); + } + } + }); + + return foundExtensions; } /** @@ -477,8 +547,10 @@ export class Configuration { if (config.comments.blockComment.includes("/*")) { // console.log(langId, config.comments); - // If Language ID isn't already in the langArray... - if (!langArray.includes(langId)) { + // If Language ID isn't already in the langArray AND + // the langId isn't set as disabled... + if (!langArray.includes(langId) && !this.isLangIdDisabled(langId)) { + // Add it to the array. langArray.push(langId); } } @@ -495,9 +567,10 @@ export class Configuration { langArray = []; for (let langId of multiLineStyleBlocksLangs) { // If langId is exists (ie. not NULL or empty string) AND - // the array doesn't already include langId, - // then add it to the array. - if (langId && !langArray.includes(langId)) { + // the array doesn't already include langId, AND + // the langId isn't set as disabled... + if (langId && !langArray.includes(langId) && !this.isLangIdDisabled(langId)) { + // Add it to the array. langArray.push(langId); } } @@ -540,9 +613,10 @@ export class Configuration { style = ";"; } - // If style any empty string, (i.e. not an unsupported single-line - // comment like bat's @rem)... - if (style != "") { + // If style is NOT an empty string, (i.e. not an unsupported single-line + // comment like bat's @rem), AND + // the langId isn't set as disabled... + if (style != "" && !this.isLangIdDisabled(langId)) { // Set the langId and it's style into the Map. tempMap.set(langId, style); } @@ -559,6 +633,9 @@ export class Configuration { // Get user-customized langIds for the //-style and add to the map. let customSlashLangs = this.getConfigurationValue("slashStyleBlocks"); for (let langId of customSlashLangs) { + // If langId is exists (ie. not NULL or empty string) AND + // the langId is longer than 0, AND + // the langId isn't set as disabled... if (langId && langId.length > 0) { tempMap.set(langId, "//"); } @@ -567,7 +644,10 @@ export class Configuration { // Get user-customized langIds for the #-style and add to the map. let customHashLangs = this.getConfigurationValue("hashStyleBlocks"); for (let langId of customHashLangs) { - if (langId && langId.length > 0) { + // If langId is exists (ie. not NULL or empty string) AND + // the langId is longer than 0, AND + // the langId isn't set as disabled... + if (langId && langId.length > 0 && !this.isLangIdDisabled(langId)) { tempMap.set(langId, "#"); } } @@ -575,7 +655,10 @@ export class Configuration { // Get user-customized langIds for the ;-style and add to the map. let customSemicolonLangs = this.getConfigurationValue("semicolonStyleBlocks"); for (let langId of customSemicolonLangs) { - if (langId && langId.length > 0) { + // If langId is exists (ie. not NULL or empty string) AND + // the langId is longer than 0, AND + // the langId isn't set as disabled... + if (langId && langId.length > 0 && !this.isLangIdDisabled(langId)) { tempMap.set(langId, ";"); } } @@ -590,10 +673,13 @@ export class Configuration { * either multi-line-languages.json, or single-line-languages.json. */ private writeCommentLanguageDefinitionsToJsonFile() { + // Ensure the auto-generated directory exists. + utils.ensureDirExists(this.autoGeneratedDir); + // Write the into the single-line-languages.json file. - this.writeJsonFile(this.singleLineLangDefinitionFilePath, this.convertMapToReversedObject(this.singleLineBlocksMap)); + utils.writeJsonFile(this.singleLineLangDefinitionFilePath, utils.convertMapToReversedObject(this.singleLineBlocksMap)); // Write the into the multi-line-languages.json file. - this.writeJsonFile(this.multiLineLangDefinitionFilePath, Object.fromEntries(this.multiLineBlocksMap)); + utils.writeJsonFile(this.multiLineLangDefinitionFilePath, Object.fromEntries(this.multiLineBlocksMap)); } /** @@ -620,19 +706,15 @@ export class Configuration { */ private setLanguageConfiguration(langId: string, multiLine?: boolean, singleLineStyle?: string): vscode.Disposable { const internalLangConfig: vscode.LanguageConfiguration = this.getLanguageConfig(langId); - const defaultMultiLineConfig: any = this.readJsonFile(`${__dirname}/../../config/default-multi-line-config.json`); + const defaultMultiLineConfig: any = utils.readJsonFile(`${__dirname}/../../config/default-multi-line-config.json`); let langConfig = {...internalLangConfig}; if (multiLine) { - langConfig.autoClosingPairs = this.mergeConfigProperty( - defaultMultiLineConfig.autoClosingPairs, - internalLangConfig?.autoClosingPairs, - "open" - ); + langConfig.autoClosingPairs = utils.mergeArraysBy(defaultMultiLineConfig.autoClosingPairs, internalLangConfig?.autoClosingPairs, "open"); // Add the multi-line onEnter rules to the langConfig. - langConfig.onEnterRules = this.mergeConfigProperty(Rules.multilineEnterRules, internalLangConfig?.onEnterRules, "beforeText"); + langConfig.onEnterRules = utils.mergeArraysBy(Rules.multilineEnterRules, internalLangConfig?.onEnterRules, "beforeText"); // Only assign the default config comments if it doesn't already exist. // (nullish assignment operator ??=) @@ -661,15 +743,15 @@ export class Configuration { if (isOnEnter && singleLineStyle) { // //-style comments if (singleLineStyle === "//") { - langConfig.onEnterRules = this.mergeConfigProperty(Rules.slashEnterRules, langConfig?.onEnterRules, "beforeText"); + langConfig.onEnterRules = utils.mergeArraysBy(Rules.slashEnterRules, langConfig?.onEnterRules, "beforeText"); } // #-style comments else if (singleLineStyle === "#") { - langConfig.onEnterRules = this.mergeConfigProperty(Rules.hashEnterRules, langConfig?.onEnterRules, "beforeText"); + langConfig.onEnterRules = utils.mergeArraysBy(Rules.hashEnterRules, langConfig?.onEnterRules, "beforeText"); } // ;-style comments else if (singleLineStyle === ";") { - langConfig.onEnterRules = this.mergeConfigProperty(Rules.semicolonEnterRules, langConfig?.onEnterRules, "beforeText"); + langConfig.onEnterRules = utils.mergeArraysBy(Rules.semicolonEnterRules, langConfig?.onEnterRules, "beforeText"); } } // If isOnEnter is false AND singleLineStyle isn't false, i.e. a string. @@ -697,15 +779,15 @@ export class Configuration { langConfig.onEnterRules.forEach((item) => { // Check if the item has a "beforeText" property and reconstruct its regex pattern. if (Object.hasOwn(item, "beforeText")) { - item.beforeText = this.reconstructRegex(item, "beforeText"); + item.beforeText = utils.reconstructRegex(item, "beforeText"); } // Check if the item has an "afterText" property and reconstruct its regex pattern. if (Object.hasOwn(item, "afterText")) { - item.afterText = this.reconstructRegex(item, "afterText"); + item.afterText = utils.reconstructRegex(item, "afterText"); } // Check if the item has an "afterText" property and reconstruct its regex pattern. if (Object.hasOwn(item, "previousLineText")) { - item.previousLineText = this.reconstructRegex(item, "previousLineText"); + item.previousLineText = utils.reconstructRegex(item, "previousLineText"); } }); } @@ -721,17 +803,17 @@ export class Configuration { // If langConfig has a wordPattern key... if (Object.hasOwn(langConfig, "wordPattern")) { - langConfig.wordPattern = this.reconstructRegex(langConfig, "wordPattern"); + langConfig.wordPattern = utils.reconstructRegex(langConfig, "wordPattern"); } // If langConfig has a folding key... if (Object.hasOwn(langConfig, "folding")) { // @ts-ignore error TS2339: Property 'folding' does not exist on type if (Object.hasOwn(langConfig.folding, "markers")) { // @ts-ignore error TS2339: Property 'folding' does not exist on type - langConfig.folding.markers.start = this.reconstructRegex(langConfig.folding.markers, "start"); + langConfig.folding.markers.start = utils.reconstructRegex(langConfig.folding.markers, "start"); // @ts-ignore error TS2339: Property 'folding' does not exist on type - langConfig.folding.markers.end = this.reconstructRegex(langConfig.folding.markers, "end"); + langConfig.folding.markers.end = utils.reconstructRegex(langConfig.folding.markers, "end"); } } // If langConfig has a indentationRules key... @@ -742,19 +824,19 @@ export class Configuration { for (let key in indentationRules) { // If the key is "increaseIndentPattern", reconstruct the regex pattern. if (key === "increaseIndentPattern") { - indentationRules.increaseIndentPattern = this.reconstructRegex(indentationRules, "increaseIndentPattern"); + indentationRules.increaseIndentPattern = utils.reconstructRegex(indentationRules, "increaseIndentPattern"); } // If the key is "decreaseIndentPattern", reconstruct the regex pattern. if (key === "decreaseIndentPattern") { - indentationRules.decreaseIndentPattern = this.reconstructRegex(indentationRules, "decreaseIndentPattern"); + indentationRules.decreaseIndentPattern = utils.reconstructRegex(indentationRules, "decreaseIndentPattern"); } // If the key is "indentNextLinePattern", reconstruct the regex pattern. if (key === "indentNextLinePattern") { - indentationRules.indentNextLinePattern = this.reconstructRegex(indentationRules, "indentNextLinePattern"); + indentationRules.indentNextLinePattern = utils.reconstructRegex(indentationRules, "indentNextLinePattern"); } // If the key is "unIndentedLinePattern", reconstruct the regex pattern. if (key === "unIndentedLinePattern") { - indentationRules.unIndentedLinePattern = this.reconstructRegex(indentationRules, "unIndentedLinePattern"); + indentationRules.unIndentedLinePattern = utils.reconstructRegex(indentationRules, "unIndentedLinePattern"); } } } @@ -779,130 +861,6 @@ export class Configuration { return vscode.languages.setLanguageConfiguration(langId, langConfig); } - /** - * Merges two configuration properties arrays, removing any duplicates based on a - * specified property. - * - * @param {any[]} defaultConfigProperty The default configuration property array of objects. - * @param {any[]} internalConfigProperty The internal configuration property array of objects. - * @param {string} objectKey The key within the array item object to check against for preventing duplicates - * @returns {any[]} The merged configuration property array without duplicates. - */ - private mergeConfigProperty(defaultConfigProperty: any[], internalConfigProperty: any[], objectKey: string) { - // Define an empty array if the internalConfigProperty is undefined. - internalConfigProperty ??= []; - - // Copy to avoid side effects. - const merged = [...defaultConfigProperty]; - - /** - * Merge the arrays and remove any duplicates. - */ - - // Loop over the internalConfigProperty array... - internalConfigProperty.forEach((item) => - // Test all items in the merged array, and if the item's - // key is not already present in one of the merged array's objects then add the item - // to the merged array. - // - // Code based on "2023 update" portion of this StackOverflow answer: - // https://stackoverflow.com/a/1584377/2358222 - merged.some((mergedItem) => item[objectKey] === mergedItem[objectKey]) ? null : merged.push(item) - ); - - return merged; - } - - /** - * Reconstruct the regex pattern because vscode doesn't like the regex pattern as a string, - * or some patterns are not working as expected. - * - * @param obj The object - * @param key The key to check in the object - * @returns {RegExp} The reconstructed regex pattern. - */ - private reconstructRegex(obj: any, key: string) { - // If key has a "pattern" key, then it's an object... - if (Object.hasOwn(obj[key], "pattern")) { - return new RegExp(obj[key].pattern); - } - // Otherwise it's a string. - else { - return new RegExp(obj[key]); - } - } - - /** - * Convert a Map to an object with it's inner Map's keys and values reversed/switched. - * - * Code based on this StackOverflow answer https://stackoverflow.com/a/45728850/2358222 - * - * @param {Map>} m The Map to convert to an object. - * @returns {object} The converted object. - * - * @example - * reverseMapping( - * Map { - * "supportedLanguages" => Map { - * "apacheconf" => "#", - * "c" => "//", - * "clojure" => ";", - * "coffeescript" => "#", - * "cpp" => "//", - * … - * } - * } - * ); - * - * // Converts to: - * - * { - * "supportedLanguages" => { - * "#": [ - * "apacheconf", - * "coffeescript", - * ... - * ], - * "//": [ - * "c", - * "cpp", - * ... - * ], - * ";": [ - * "clojure", - * ... - * ] - * } - * } - */ - private convertMapToReversedObject(m: Map>): object { - const result: any = {}; - - // Convert a nested key:value Map from inside another Map into an key:array object, - // while reversing/switching the keys and values. The Map's values are now the keys of - // the object and the Map's keys are now added as the values of the array. The reversed - // object is added to the key of the outerMap. - - // Loop through the outer Map... - for (const [key, innerMap] of m.entries()) { - // Convert the inner Map to an object - const o = Object.fromEntries(innerMap); - - // Reverse the inner object mapping. - // - // Loop through the object (o) keys, assigns a new object (r) with the value of the - // object key (k) as the new key (eg. "//") and the new value is an array of all - // the original object keys (o[k]) (eg. "php"). - // If the key (o[k]) already exists in the new object (r), then just add the - // original key to the array, otherwise start a new array ([]) with the original - // key as value ( (r[o[k]] || []).concat(k) ). - // Add this new reversed object to the result object with the outer map key - // as the key. - result[key] = Object.keys(o).reduce((r, k) => Object.assign(r, {[o[k]]: (r[o[k]] || []).concat(k)}), {}); - } - return result; - } - /** * The keyboard binding event handler for the single-line blocks on shift+enter. * @@ -969,7 +927,7 @@ export class Configuration { */ private handleChangeBladeMultiLineBlock(textEditor: vscode.TextEditor) { let langId = textEditor.document.languageId; - const extensionNames = this.getExtensionNames(); + const extensionName = this.extensionData.get("name"); // Only carry out function if languageId is blade. if (langId === "blade" && !this.isLangIdDisabled(langId)) { @@ -993,7 +951,7 @@ export class Configuration { // then output a message to the user. else if (langId == "blade" && this.isLangIdDisabled(langId)) { vscode.window.showInformationMessage( - `Blade is set as disabled in the "${extensionNames.name}.disabledLanguages" setting. The "${extensionNames.name}.bladeOverrideComments" setting will have no affect.`, + `Blade is set as disabled in the "${extensionName}.disabledLanguages" setting. The "${extensionName}.bladeOverrideComments" setting will have no affect.`, "OK" ); @@ -1001,4 +959,55 @@ export class Configuration { this.setBladeComments(false); } } + + /** + * Logs the environment, configuration settings, and language configs for debugging purposes. + */ + private logDebugInfo() { + // The path to the built-in extensions. The env variable changes when on WSL. + // So we can use it for both Windows and WSL. + const builtInExtensionsPath = this.extensionData.get("builtInExtensionsPath"); + + let extensionsPaths = {}; + + if (isWsl) { + // Get the Windows user and built-in extensions paths. + const windowsUserExtensionsPath = this.extensionData.get("WindowsUserExtensionsPathFromWsl"); + const windowsBuiltInExtensionsPath = this.extensionData.get("WindowsBuiltInExtensionsPathFromWsl"); + + extensionsPaths = { + "Windows-installed Built-in Extensions Path": windowsBuiltInExtensionsPath, + "Windows-installed User Extensions Path": windowsUserExtensionsPath, + "WSL-installed Built-in Extensions Path": builtInExtensionsPath, + "WSL-installed User Extensions Path": this.extensionData.get("userExtensionsPath"), + }; + } else { + extensionsPaths = { + "Built-in Extensions Path": builtInExtensionsPath, + "User Extensions Path": this.extensionData.get("userExtensionsPath"), + }; + } + + const env = { + "OS": process.platform, + "Platform": process.platform, + "VS Code Details": { + "Version": vscode.version, + "Remote Name": vscode.env.remoteName || "local", + "Host": vscode.env.appHost, + ...extensionsPaths, + }, + "Other System Env Variables": process.env, + }; + this.logger.debug("Environment:", env); + + // Log the extension's user configuration settings. + this.logger.debug("Configuration settings:", this.getConfiguration()); + + // Log the objects for debugging purposes. + this.logger.debug("The language config filepaths found are:", this.languageConfigFilePaths); + this.logger.debug("The language configs found are:", this.languageConfigs); + this.logger.debug("The supported languages for multi-line blocks:", utils.readJsonFile(this.multiLineLangDefinitionFilePath)); + this.logger.debug("The supported languages for single-line blocks:", utils.readJsonFile(this.singleLineLangDefinitionFilePath)); + } } diff --git a/src/extension.ts b/src/extension.ts index 87da677..a5c2c2a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,23 +4,24 @@ import * as vscode from "vscode"; import {Configuration} from "./configuration"; import {Logger} from "./logger"; +import {ExtensionData} from "./extensionData"; const logger = new Logger(); +const extensionData = new ExtensionData(); logger.setupOutputChannel(); let configuration = new Configuration(logger); const disposables: vscode.Disposable[] = []; export function activate(context: vscode.ExtensionContext) { - const configureCommentBlocksDisposable = configuration.configureCommentBlocks(context); + const configureCommentBlocksDisposable = configuration.configureCommentBlocks(); const registerCommandsDisposable = configuration.registerCommands(); disposables.push(...configureCommentBlocksDisposable, ...registerCommandsDisposable); - const extensionNames = configuration.getExtensionNames(); + const extensionName = extensionData.get("name"); - const extensionName = extensionNames.name; - const extensionDisplayName = extensionNames.displayName; + const extensionDisplayName = extensionData.get("displayName"); let disabledLangConfig: string[] = configuration.getConfigurationValue("disabledLanguages"); @@ -154,7 +155,7 @@ export function activate(context: vscode.ExtensionContext) { // Called when active editor language is changed, so re-configure the comment blocks. vscode.workspace.onDidOpenTextDocument(() => { logger.info("Active editor language changed, re-configuring comment blocks."); - const configureCommentBlocksDisposable = configuration.configureCommentBlocks(context); + const configureCommentBlocksDisposable = configuration.configureCommentBlocks(); disposables.push(...configureCommentBlocksDisposable); }); diff --git a/src/extensionData.ts b/src/extensionData.ts new file mode 100644 index 0000000..473513a --- /dev/null +++ b/src/extensionData.ts @@ -0,0 +1,112 @@ +import * as vscode from "vscode"; +import * as path from "path"; +import isWsl from "is-wsl"; +import {IPackageJson} from "package-json-type"; + +import {readJsonFile} from "./utils"; + +export class ExtensionData { + /** + * This extension details in the form of a key:value Map object. + * + * @type {Map} + */ + private extensionData = new Map(); + + /** + * The package.json data for this extension. + * + * @type {IPackageJson} + */ + private packageJsonData: IPackageJson; + + public constructor() { + this.packageJsonData = this.getExtensionPackageJsonData(); + this.setExtensionData(); + } + + /** + * Get the names, id, and version of this extension from package.json. + * + * @returns {IPackageJson} The package.json data for this extension, with extra custom keys. + */ + private getExtensionPackageJsonData(): IPackageJson { + const extensionPath = path.join(__dirname, "../../"); + + const packageJSON: IPackageJson = readJsonFile(path.join(extensionPath, "package.json")); + + // Set the id (publisher.name) into the packageJSON object as a new `id` key. + packageJSON.id = `${packageJSON.publisher}.${packageJSON.name}`; + packageJSON.extensionPath = extensionPath; + + // The configuration settings namespace is a shortened version of the extension name. + // We just need to replace "automatic" with "auto" in the name. + const settingsNamespace: string = packageJSON.name.replace("automatic", "auto"); + // Set the namespace to the packageJSON `configuration` object as a new `namespace` key. + packageJSON.contributes.configuration.namespace = settingsNamespace; + + return packageJSON; + } + + /** + * Set the extension data into the extensionData Map. + */ + private setExtensionData() { + // Set all entries in the extensionData Map. + Object.entries(this.createExtensionData()).forEach(([key, value]) => { + this.extensionData.set(key, value); + }); + } + + /** + * Create the extension data object for the extensionData Map. + * It also helps for type inference intellisense in the get method. + * + * @returns The extension data object with keys and values. + */ + private createExtensionData() { + // The path to the user extensions. + const userExtensionsPath = isWsl + ? path.join(vscode.env.appRoot, "../../", "extensions") + : path.join(this.packageJsonData.extensionPath, "../"); + + // Set the keys and values for the Map. + // The keys will also be used for type inference in VSCode intellisense. + return { + id: this.packageJsonData.id, + name: this.packageJsonData.contributes.configuration.namespace, + displayName: this.packageJsonData.displayName, + version: this.packageJsonData.version, + userExtensionsPath: userExtensionsPath, + // The path to the built-in extensions. + // This env variable changes when on WSL to it's WSL-built-in extensions path. + builtInExtensionsPath: path.join(vscode.env.appRoot, "extensions"), + + // Only set these if running in WSL. + ...(isWsl && { + WindowsUserExtensionsPathFromWsl: path.dirname(process.env.VSCODE_WSL_EXT_LOCATION!), + WindowsBuiltInExtensionsPathFromWsl: path.join(process.env.VSCODE_CWD!, "resources/app/extensions"), + }), + } as const; + } + + /** + * Get the extension's data by a specified key. + * + * @param {K} key The key of the extension detail to get. + * + * @returns {ReturnType[K] | undefined} The value of the extension detail, or undefined if the key does not exist. + */ + public get>(key: K): ReturnType[K] | undefined { + return this.extensionData.get(key) as ReturnType[K] | undefined; + } + + /** + * Get all extension data. + * + * @returns {ReadonlyMap} A read-only Map containing all extension details. + */ + public getAll(): ReadonlyMap { + return this.extensionData; + } +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..1ffffeb --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,159 @@ +import * as fs from "node:fs"; +import * as jsonc from "jsonc-parser"; + +/** + * Read the file and parse the JSON. + * + * @param {string} filepath The path of the file. + * + * @returns The file content. + */ +export function readJsonFile(filepath: string): any { + return jsonc.parse(fs.readFileSync(filepath).toString()); +} + +/** + * Read the file and parse the JSON. + * + * @param {string} filepath The path of the file. + * @param {any} data The data to write into the file. + * @returns The file content. + */ +export function writeJsonFile(filepath: string, data: any): any { + // Write the updated JSON back into the file and add tab indentation + // to make it easier to read. + fs.writeFileSync(filepath, JSON.stringify(data, null, "\t")); +} + +/** + * Ensure that the directory exists. If it doesn't exist, create it. + * + * @param {string} dir The directory path to ensure exists. + */ +export function ensureDirExists(dir: string) { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + } +} + +/** + * Reconstruct the regex pattern because vscode doesn't like the regex pattern as a string, + * or some patterns are not working as expected. + * + * @param obj The object + * @param key The key to check in the object + * @returns {RegExp} The reconstructed regex pattern. + */ +export function reconstructRegex(obj: any, key: string) { + // If key has a "pattern" key, then it's an object... + if (Object.hasOwn(obj[key], "pattern")) { + return new RegExp(obj[key].pattern); + } + // Otherwise it's a string. + else { + return new RegExp(obj[key]); + } +} + +/** + * Convert a Map to an object with it's inner Map's keys and values reversed/switched. + * + * Code based on this StackOverflow answer https://stackoverflow.com/a/45728850/2358222 + * + * @param {Map>} m The Map to convert to an object. + * @returns {object} The converted object. + * + * @example + * reverseMapping( + * Map { + * "supportedLanguages" => Map { + * "apacheconf" => "#", + * "c" => "//", + * "clojure" => ";", + * "coffeescript" => "#", + * "cpp" => "//", + * … + * } + * } + * ); + * + * // Converts to: + * + * { + * "supportedLanguages" => { + * "#": ["apacheconf", "coffeescript"], + * "//": ["c", "cpp"], + * ";": ["clojure"] + * } + * } + */ +export function convertMapToReversedObject(m: Map>): object { + const result: any = {}; + + // Convert a nested key:value Map from inside another Map into an key:array object, + // while reversing/switching the keys and values. The Map's values are now the keys of + // the object and the Map's keys are now added as the values of the array. The reversed + // object is added to the key of the outerMap. + + // Loop through the outer Map... + for (const [key, innerMap] of m.entries()) { + // Convert the inner Map to an object + const o = Object.fromEntries(innerMap); + + // Reverse the inner object mapping. + // + // Loop through the object (o) keys, assigns a new object (r) with the value of the + // object key (k) as the new key (eg. "//") and the new value is an array of all + // the original object keys (o[k]) (eg. "php"). + // If the key (o[k]) already exists in the new object (r), then just add the + // original key to the array, otherwise start a new array ([]) with the original + // key as value ( (r[o[k]] || []).concat(k) ). + // Add this new reversed object to the result object with the outer map key + // as the key. + result[key] = Object.keys(o).reduce((r, k) => Object.assign(r, {[o[k]]: (r[o[k]] || []).concat(k)}), {}); + } + return result; +} + +/** + * Merges two arrays of objects, removing duplicates based on a specified property. + * + * Code based on "2023 update" portion of this StackOverflow answer: + * https://stackoverflow.com/a/1584377/2358222 + * + * @param {T[]} primaryArray The primary array of objects (takes precedence). + * @param {T[]} secondaryArray The secondary array of objects to merge in. + * @param {keyof T} key The property key to check for duplicates. + * + * @returns {T[]} The merged array without duplicates + * + * @example + * const users1 = [{id: 1, name: 'John'}, {id: 2, name: 'Jane'}]; + * const users2 = [{id: 2, name: 'Jane'}, {id: 3, name: 'Jane Doe'}]; + * const merged = mergeArraysBy(users1, users2, 'name'); + * // Result: [{id: 1, name: 'John'}, {id: 2, name: 'Jane'}, {id: 3, name: 'Jane Doe'}] + */ +export function mergeArraysBy(primaryArray: T[], secondaryArray: T[], key: keyof T): T[] { + // Handle undefined/null arrays + const primary = primaryArray || []; + const secondary = secondaryArray || []; + + // Start with primary array (avoids side effects) + const merged = [...primary]; + + // Add items from secondary array that don't exist in primary, + // removing any duplicates. + secondary.forEach((item) => { + // Test all items in the merged array to check if the value of the key + // already exists in the merged array. + const exists: boolean = merged.some((existingItem) => item[key] === existingItem[key]); + + // If the value of the key does not exist in the merged array, + // then add the item, which prevents duplicates. + if (!exists) { + merged.push(item); + } + }); + + return merged; +}