diff --git a/.github/instructions/commit-message-generation.instructions.md b/.github/instructions/commit-message-generation.instructions.md new file mode 100644 index 0000000..48aa659 --- /dev/null +++ b/.github/instructions/commit-message-generation.instructions.md @@ -0,0 +1,151 @@ +--- +description: Apply only when generating a suggested git commit message from the VS Code Source Control "Generate Commit Message" action. Do not use for staging files or running git commits. +--- + +# Commit Message Generation + +Use this instruction only to generate a commit message suggestion from the current git changes shown in Source Control. It must not stage files, create a commit, or perform any git action that changes repository state. + +## Workflow + +- Base the message on the currently staged changes when staged changes exist. +- If nothing is staged, base the message on the current unstaged working tree changes. +- Summarise the actual change, not the user's intent or ticket title. +- Only use a scope on `docs`, `chore`, `build`, and `ci` commits, and only when it meaningfully narrows the audience or area. Omit the scope on all other types. +- Return only the proposed commit message. + +## Output Quality Examples + +The following shows the same diff handled correctly and incorrectly. + +**Bad** — raw diff tokens leaked into the message: + +``` +refactor(U pouvoirs Ret coins): update config methods to use helper traits 文 আ obстоятельsspaq Tritur disposto மற்றும்... +``` + +**Good** — high-level summary inferred from file paths and clear hunks only: + +``` +refactor: extract repeated config resolution logic into helper methods +``` + +If the diff is too noisy to summarise accurately, use a safe generic fallback: + +``` +chore: update project files +``` + +## Format + +``` +[optional scope]: + +[optional body] + +[optional footer(s)] +``` + +- **Present tense, imperative mood**: "add feature" not "added feature" +- **Scope**: lowercase, in parentheses — only permitted on `docs`, `chore`, `build`, and `ci` types; omit on all others +- **Description**: under 72 characters, concise, and in sentence case; preserve original casing for code/class references in backticks + +## Commit Types + +This repository extends the standard Conventional Commits types with additional project-specific types. + +| Type | When to use | +| ----------- | ----------------------------------------------------------- | +| `feat` | New feature | +| `fix` | Bug fix | +| `docs` | Documentation and docblocks only | +| `style` | Formatting/whitespace, missing semi-colons, no logic change | +| `refactor` | Code restructuring, no behaviour change | +| `perf` | Performance improvement | +| `test` | Add or update tests | +| `build` | Build system or dependency changes | +| `chore` | Maintenance, tooling, config, version bumps | +| `ci` | CI/CD pipeline | +| `revert` | Revert a previous commit | +| `remove` | Remove code or files | +| `security` | Security-related changes | +| `deprecate` | Deprecation-related changes | + +## Custom Type Examples + +Prefer these extended types when they describe the change more accurately than `refactor`, `fix`, or `chore`. + +- Use `remove` only when code or files are actually deleted, not when they are merely moved or refactored. +- Use `security` when security risk reduction is the primary intent and outcome, not for unrelated fixes. +- Use `deprecate` only when introducing or documenting a deprecation path, not when fully removing the deprecated code. + +``` +remove(drivers): delete legacy driver compatibility shim + +security(nginx): harden fastcgi param handling for site isolation + +deprecate(config): mark `php_port` as deprecated in favour of `php81_port` +``` + +## Breaking Changes + +Use `!` after type/scope and add a `BREAKING CHANGE:` footer: + +``` +feat!: rename PHP port config key + +BREAKING CHANGE: `php_port` renamed to `php_port_override` +``` + +## Body & Footers + +Add a body when the _why_ is not obvious from the subject line. + +Body guidance: + +- Explain why the change was needed when it is not already obvious. +- Include relevant technical context only when it improves clarity. +- Use sentence case and proper punctuation. +- Use bullet points only when listing multiple distinct changes. +- Always separate each bullet point with a blank line. +- When referring to methods across multiple classes, prefix them with the class name, for example `ClassName::methodName`. + +Footer guidance: + +- Put issue references in the footer, for example `Closes #36` or `Refs #36`. + +**Bad** — wrong case, missing backticks, footer buried in body, no blank line before footer: + +``` +fix - Fixed the field variable + +fixed $field to $correctField in Config getValue method. also updated tests. closes #36 +``` + +**Good**: + +``` +fix: correct variable replacement + +Variable was missed when replacement happened, causing errors. + +- Fixed incorrect `$field` variable name to `$correctField` in `Config::getValue` method. + +- Updated tests to cover this case. + +Closes #36 +``` + +## Best Practices + +- Review the current Source Control changes before generating the message. +- Ensure the entire message is professional and clearly communicates the purpose of the commit. +- Important: Use `docs` for JSDoc additions/changes, not `refactor`. +- Use **British English** spelling for commit messages (e.g. "optimise" not "optimize", "colour", not "color") to maintain consistency with existing messages. +- Always enclose code references in backticks (e.g. `php_port`). +- Always separate each bullet point with a blank line. + +## Safety Rules + +- Never stage files, commit changes, amend commits, or push. +- Never suggest including secrets, credentials, or private keys in a commit. diff --git a/package.json b/package.json index 3bfd8c2..ade9ab5 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,24 @@ "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` (macOS: `cmd + shift + m`). If `blade` language ID is set in the disabledLanguages, then the HTML `` comments will be used." + }, + "auto-comment-blocks.logLevel": { + "scope": "resource", + "type": "string", + "enum": [ + "debug", + "info", + "error", + "off" + ], + "markdownEnumDescriptions": [ + "Log debug, info, and errors", + "Log info and errors", + "Log errors only", + "Disable logging" + ], + "default": "debug", + "markdownDescription": "Set the logging level. `debug` is the most verbose, and `off` disables all logging, except for those special few labelled as 'important'." } } }, diff --git a/src/configuration.ts b/src/configuration.ts index 3acb96d..1704b6b 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -81,10 +81,6 @@ export class Configuration { ***********/ public constructor() { - // Always output extension information to channel on activate. - logger.debug(`Extension details:`, this.extensionData.getAll()); - logger.debug(`Extension Discovery Paths:`, this.extensionData.getAllExtensionDiscoveryPaths()); - this.findAllLanguageConfigFilePaths(); this.setLanguageConfigDefinitions(); @@ -985,6 +981,11 @@ export class Configuration { * Logs the environment, configuration settings, and language configs for debugging purposes. */ private logDebugInfo() { + // If debug logging is not enabled, exit early. + if (!logger.isDebugEnabled()) { + return; + } + // 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.getExtensionDiscoveryPath("builtInExtensionsPath"); diff --git a/src/extension.ts b/src/extension.ts index c76915f..31b9a69 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,11 +6,15 @@ import {Configuration} from "./configuration"; import {logger} from "./logger"; import {ExtensionData} from "./extensionData"; import {addDevEnvVariables} from "./utils"; +import {LogLevel} from "./interfaces/utils"; export function activate(context: vscode.ExtensionContext) { // Setup logger first logger.setupOutputChannel(); + const initialLogLevel = vscode.workspace.getConfiguration("auto-comment-blocks").get("logLevel", "debug"); + logger.setLogLevel(initialLogLevel); + // Only load dev environment variables when not in production if (context.extensionMode !== vscode.ExtensionMode.Production) { addDevEnvVariables(); @@ -18,6 +22,12 @@ export function activate(context: vscode.ExtensionContext) { // Initialize extension data and configuration const extensionData = new ExtensionData(null, true); + + // Always output extension information to channel on activate. + logger.important(`Activating ${extensionData.get("id")} v${extensionData.get("version")}`); + logger.debug(`Extension details:`, extensionData.getAll(true)); + logger.debug(`Extension Discovery Paths:`, extensionData.getAllExtensionDiscoveryPaths()); + const configuration = new Configuration(); const extensionName = extensionData.get("namespace"); const extensionDisplayName = extensionData.get("displayName"); @@ -57,6 +67,14 @@ export function activate(context: vscode.ExtensionContext) { } } + /** + * Logging Level + */ + if (event.affectsConfiguration(`${extensionName}.logLevel`)) { + const logLevel = configuration.getConfigurationValue("logLevel"); + logger.setLogLevel(logLevel); + } + // Settings that require an extension host reload when changed. const reloadRequiredSettings = [ "disabledLanguages", @@ -83,10 +101,19 @@ export function activate(context: vscode.ExtensionContext) { * language id of a text document has been changed. As described in * https://github.com/microsoft/vscode/blob/4e8fbaef741afebd24684b88cac47c2f44dfb8eb/src/vscode-dts/vscode.d.ts#L13716-L13728 * - * Called when active editor language is changed, so re-configure the comment blocks. + * Re-configuring the comment blocks here protects against other extensions activating + * after this extension and overriding our language configuration, which would cause our + * comment blocks to not work properly (e.g `/*!`). */ - const documentOpenDisposable = vscode.workspace.onDidOpenTextDocument(() => { - logger.info("Active editor language changed, re-configuring comment blocks."); + const documentOpenDisposable = vscode.workspace.onDidOpenTextDocument((e) => { + // If the document is not a file or untitled scheme, then return early for + // virtual documents (e.g. git, output, etc. panels), as we only need to + // re-configure comment blocks for normal files. + if (e.uri.scheme !== "file" && e.uri.scheme !== "untitled") { + return; + } + + logger.info(`Document opened or language changed to "${e.languageId}", re-configuring comment blocks.`); // Dispose of old comment block configurations to prevent memory leaks commentBlocksDisposables.forEach((disposable) => disposable.dispose()); diff --git a/src/extensionData.ts b/src/extensionData.ts index 221693e..3d9305e 100644 --- a/src/extensionData.ts +++ b/src/extensionData.ts @@ -310,13 +310,31 @@ export class ExtensionData { * * @returns {ExtensionMetaData} A plain object containing all extension details. */ - public getAll(): ExtensionMetaData | null { + public getAll(prepareForLogging: boolean = false): ExtensionMetaData | null { // If no data, return null if (this.extensionData.size === 0) { return null; } - return Object.fromEntries(this.extensionData) as unknown as ExtensionMetaData; + const data = prepareForLogging ? this.prepareForLogging() : this.extensionData; + + return Object.fromEntries(data) as unknown as ExtensionMetaData; + } + + /** + * Prepare the extension data for logging by cloning it and removing irrelevant + * information, without mutating the original data. + * + * @returns {Map} + * The cloned, redacted extension data Map. + */ + private prepareForLogging(): Map { + const extensionDataClone = new Map(this.extensionData); + + // Remove the packageJSON entry to avoid logging irrelevant information. + extensionDataClone.delete("packageJSON"); + + return extensionDataClone; } /** diff --git a/src/interfaces/settings.ts b/src/interfaces/settings.ts index da080b7..577269c 100644 --- a/src/interfaces/settings.ts +++ b/src/interfaces/settings.ts @@ -1,3 +1,5 @@ +import {LogLevel} from "./utils"; + export interface Settings { singleLineBlockOnEnter: boolean; disabledLanguages: string[]; @@ -7,4 +9,5 @@ export interface Settings { multiLineStyleBlocks: string[]; overrideDefaultLanguageMultiLineComments: Record; bladeOverrideComments: boolean; + logLevel: LogLevel; } diff --git a/src/interfaces/utils.ts b/src/interfaces/utils.ts index 404880f..6f174fb 100644 --- a/src/interfaces/utils.ts +++ b/src/interfaces/utils.ts @@ -37,3 +37,18 @@ export interface MultiLineLanguageDefinitions extends JsonObject { * Language ID */ export type LanguageId = string; + +/** + * Log levels + */ +export const logLevels = { + debug: "debug", + info: "info", + error: "error", + off: "off", +} as const; + +/** + * Log level union type, derived from the keys of the logLevels object. + */ +export type LogLevel = (typeof logLevels)[keyof typeof logLevels]; diff --git a/src/logger.ts b/src/logger.ts index 91ef9fb..2a86931 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,4 +1,5 @@ import {OutputChannel, window} from "vscode"; +import {LogLevel, logLevels} from "./interfaces/utils"; /** * Logger class for the Auto Comment Blocks extension. @@ -19,12 +20,11 @@ class Logger { private outputChannel: OutputChannel; /** - * Whether to log `debug` level messages or not. - * Set to `true` by default. + * Current log level. * - * @type {boolean} + * @type {LogLevel} */ - private debugMode = true; + private logLevel: LogLevel = "debug"; /*********** * Methods * @@ -44,14 +44,29 @@ class Logger { } /** - * Turn debug mode on or off. Off will disable debug messages. + * Set the log level. * - * TODO: Possibly add a toggle setting in the extension user settings. + * @param {LogLevel} level Desired log level. + */ + public setLogLevel(level: LogLevel | string): void { + // If the provided log level is not valid, default to "debug" and log an error message. + if (!this.isValidLogLevel(level)) { + this.logLevel = "debug"; + logger.error(`Invalid log level: "${level}". Defaulting to "debug".`); + return; + } + + this.logLevel = level; + } + + /** + * Check if the provided log level is valid. + * @param level The log level to check. * - * @param {boolean} debug Whether to enable or disable debug mode. + * @returns `true` if the log level is valid, `false` otherwise. */ - public setDebugMode(debug: boolean): void { - this.debugMode = debug; + private isValidLogLevel(level: string): level is LogLevel { + return (Object.values(logLevels) as string[]).includes(level); } /** @@ -76,7 +91,9 @@ class Logger { * @param {string} message The message to be logged. */ public info(message: string): void { - this.logMessage("INFO", message); + if (this.shouldLog("info")) { + this.logMessage("INFO", message); + } } /** @@ -87,7 +104,7 @@ class Logger { * @param {unknown} data [Optional] Extra data that is useful for debugging, like an object or array. */ public debug(message: string, data?: unknown): void { - if (this.debugMode) { + if (this.shouldLog("debug")) { this.logMessage("DEBUG", message, data); } } @@ -99,7 +116,48 @@ class Logger { * @param {Error} error An Error object. */ public error(message: string, error?: Error): void { - this.logMessage("ERROR", message, error); + if (this.shouldLog("error")) { + this.logMessage("ERROR", message, error); + } + } + + /** + * Send an important message to the output channel. + * This is a special log level that is always emitted regardless of the log level, + * and should be used sparingly. + * + * @param {string} message The message to be logged. + */ + public important(message: string): void { + this.logMessage("IMPORTANT", message); + } + + /** + * Determine whether debug logging is enabled. + * @returns `true` if debug logging is enabled, `false` otherwise. + */ + public isDebugEnabled(): boolean { + return this.shouldLog("debug"); + } + + /** + * Determine whether a log should be emitted for the current level. + * + * @param {LogLevel} requiredLevel The minimum level required to emit the log. + * + * @returns {boolean} Whether the log should be emitted. + */ + private shouldLog(requiredLevel: LogLevel): boolean { + // Numeric weights used for level comparison. + const levelWeight: Record = { + debug: 3, // Emits debug, info, and error logs - the most verbose level. + info: 2, // Emits info and error logs. + error: 1, // Emits error logs only. + off: 0, // Disables all logs, except for the special "important" logs that are always emitted. + }; + + // Emit when the configured level is at least as verbose as the requested level. + return levelWeight[this.logLevel] >= levelWeight[requiredLevel]; } /** @@ -116,13 +174,16 @@ class Logger { const time = new Date().toLocaleTimeString(); // Output the log message to the output channel. - this.outputChannel.appendLine(`["${level}" - ${time}] ${message}`); + this.outputChannel.append(`["${level}" - ${time}] ${message}`); if (meta) { const data: string = this.formatMeta(message, meta); - // Output the meta data to the output channel. - this.outputChannel.appendLine(data); + // Output the meta data to the output channel with a leading space. + this.outputChannel.appendLine(` ${data}`); + } else { + // Output a new line to the output channel. + this.outputChannel.appendLine(""); } } diff --git a/src/utils.ts b/src/utils.ts index de2752b..3e5f2d6 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -30,17 +30,31 @@ export function readJsonFile(filepath: string, return null; } - const jsonErrors: jsonc.ParseError[] = []; - // Read the contents of the JSON file. const fileContent = fs .readFileSync(filepath, {encoding: "utf8"}) .toString() .replace(/^\uFEFF/, ""); // Remove BOM if present. + return parseJsonContent(filepath, fileContent); +} + +/** + * Parse the JSON content and handle any parse errors. + * + * @template T The expected type of the parsed JSON content. + * @param {string} filepath The path of the file. + * @param {string} fileContent The content of the file. + * + * @returns {T} The parsed JSON content as the passed T type. + */ +function parseJsonContent(filepath: string, fileContent: string): T { + const jsonErrors: jsonc.ParseError[] = []; + // Parse the JSON content using jsonc-parser, allowing empty content and trailing commas. const jsonContents = jsonc.parse(fileContent, jsonErrors, {allowEmptyContent: true, allowTrailingComma: true}) ?? {}; + // If there are any parse errors, construct a detailed error message and throw an error. if (jsonErrors.length > 0) { const errorMessages = constructJsonParseErrorMsg(filepath, fileContent, jsonErrors); const errorMsg = "Failed to parse a required JSON file"; @@ -63,6 +77,7 @@ export function readJsonFile(filepath: string, throw error; } + // Otherwise, return the parsed JSON content. return jsonContents as T; }