|
| 1 | +/* -------------------------------------------------------------------------------------------- |
| 2 | + * Copyright (c) Microsoft Corporation. All Rights Reserved. |
| 3 | + * See 'LICENSE' in the project root for license information. |
| 4 | + * ------------------------------------------------------------------------------------------ */ |
| 5 | + |
| 6 | +import { beforeEach, describe, it } from 'mocha'; |
| 7 | +import { deepStrictEqual, strictEqual } from 'node:assert'; |
| 8 | +import { readFileSync } from 'node:fs'; |
| 9 | +import { resolve } from 'node:path'; |
| 10 | +import type { Uri } from 'vscode'; |
| 11 | +import proxyquire = require('proxyquire'); |
| 12 | + |
| 13 | +const settingName = 'codeAnalysis.clangTidy.includeCleanerMappings'; |
| 14 | +const packageJson = JSON.parse(readFileSync(resolve(__dirname, '../../../package.json'), 'utf8')); |
| 15 | +let mappings: unknown; |
| 16 | +let requestedResource: unknown; |
| 17 | + |
| 18 | +function getSetting(key: string, value: unknown): unknown { |
| 19 | + const settings = { codeAnalysis: { clangTidy: { includeCleanerMappings: value } } }; |
| 20 | + return key.split('.').reduce<unknown>((section, name) => |
| 21 | + section && typeof section === 'object' ? (section as Record<string, unknown>)[name] : undefined, settings); |
| 22 | +} |
| 23 | + |
| 24 | +const configuration = { |
| 25 | + codeAnalysis: {}, |
| 26 | + get: (key: string) => getSetting(key, mappings), |
| 27 | + inspect: (key: string) => ({ defaultValue: getSetting(key, {}) }) |
| 28 | +}; |
| 29 | +const vscode = { |
| 30 | + extensions: { getExtension: () => ({ packageJSON: packageJson }) }, |
| 31 | + workspace: { |
| 32 | + getConfiguration: (_section: string, resource: unknown) => { |
| 33 | + requestedResource = resource; |
| 34 | + return configuration; |
| 35 | + } |
| 36 | + } |
| 37 | +}; |
| 38 | +const load = proxyquire.noCallThru(); |
| 39 | +const common: typeof import('../../src/common') = load('../../src/common', { |
| 40 | + vscode, |
| 41 | + 'vscode-languageclient': {}, |
| 42 | + 'vscode-tas-client': {}, |
| 43 | + './logger': {}, |
| 44 | + './platform': {}, |
| 45 | + './telemetry': {} |
| 46 | +}); |
| 47 | +const { CppSettings }: typeof import('../../src/LanguageServer/settings') = load('../../src/LanguageServer/settings', { |
| 48 | + vscode, |
| 49 | + '../common': common, |
| 50 | + '../telemetry': {}, |
| 51 | + './client': {}, |
| 52 | + './editorConfig': {}, |
| 53 | + './extension': {}, |
| 54 | + './persistentState': {} |
| 55 | +}); |
| 56 | +const { SettingsTracker }: typeof import('../../src/LanguageServer/settingsTracker') = load('../../src/LanguageServer/settingsTracker', { |
| 57 | + vscode, |
| 58 | + '../common': common |
| 59 | +}); |
| 60 | +const schema = common.getRawSetting(`C_Cpp.${settingName}`, true); |
| 61 | + |
| 62 | +const controlCharacters = [ |
| 63 | + ...Array.from({ length: 0x20 }, (_, code) => String.fromCharCode(code)), |
| 64 | + ...Array.from({ length: 0x21 }, (_, code) => String.fromCharCode(0x7f + code)) |
| 65 | +]; |
| 66 | +const validHeaders = [ |
| 67 | + '<widget.h>', |
| 68 | + '"widget.h"', |
| 69 | + '<library/widget.hpp>', |
| 70 | + '"folder with spaces/widget.h"', |
| 71 | + '<widget>', |
| 72 | + '"folder\\widget.h"', |
| 73 | + '<${name}.h>', |
| 74 | + '<café/élément.h>', |
| 75 | + '"cafe\u0301/élément.h"' |
| 76 | +]; |
| 77 | +const invalidHeaders = [ |
| 78 | + '', '<>', '""', 'widget.h', '#include <widget.h>', |
| 79 | + '<widget.h"', '"widget.h>', '<widget.h> trailing', ' <widget.h>', |
| 80 | + '<widget.h>\n', '<widget.h>\r', '\n<widget.h>', '<widget\n.h>', '"widget\r.h"', |
| 81 | + '<widget\0.h>', '"widget\0.h"', '<widget"h>', '"widget<h"', '"widget>h"', |
| 82 | + '<widget<h>', '<widget>h>', '"widget"h"', '<widget.h>\n#include "other.h"', |
| 83 | + '"<widget.h>"', '<"widget.h">', |
| 84 | + ...controlCharacters.flatMap(control => [ |
| 85 | + `<widget${control}.h>`, `"widget${control}.h"`, |
| 86 | + `${control}<widget.h>`, `${control}"widget.h"`, |
| 87 | + `<widget.h>${control}`, `"widget.h"${control}` |
| 88 | + ]) |
| 89 | +]; |
| 90 | + |
| 91 | +describe('Include Cleaner mapping settings', () => { |
| 92 | + beforeEach(() => { |
| 93 | + mappings = undefined; |
| 94 | + requestedResource = undefined; |
| 95 | + }); |
| 96 | + |
| 97 | + it('declares a localized resource-scoped object with an empty default', () => { |
| 98 | + strictEqual(schema.type, 'object'); |
| 99 | + strictEqual(schema.scope, 'resource'); |
| 100 | + deepStrictEqual(schema.default, {}); |
| 101 | + strictEqual(schema.propertyNames.minLength, 1); |
| 102 | + strictEqual(schema.additionalProperties.type, 'string'); |
| 103 | + const messages = JSON.parse(readFileSync(resolve(__dirname, '../../../package.nls.json'), 'utf8')); |
| 104 | + strictEqual(typeof messages[schema.markdownDescription.slice(1, -1)].message, 'string'); |
| 105 | + deepStrictEqual(new CppSettings().clangTidyIncludeCleanerMappings, {}); |
| 106 | + }); |
| 107 | + |
| 108 | + it('preserves exact symbol names and literal include spellings for the resource', () => { |
| 109 | + const resource = { scheme: 'file', fsPath: '/workspace' } as Uri; |
| 110 | + const expected = Object.freeze(Object.fromEntries([ |
| 111 | + ['example::widget', '<widget.h>'], |
| 112 | + ['example::Widget', '"widget.h"'], |
| 113 | + ['example::operator*', '<operators.h>'], |
| 114 | + ['example::operator<', '<operators.h>'], |
| 115 | + ['example::operator>', '"operators.h"'], |
| 116 | + ['example::operator""_tag', '<operators.h>'], |
| 117 | + ['example::.*', '<literal.h>'], |
| 118 | + ['café::élément', '<café/élément.h>'], |
| 119 | + ['cafe\u0301::élément', '"cafe\u0301/élément.h"'], |
| 120 | + ['__proto__', '<prototype.h>'] |
| 121 | + ])); |
| 122 | + mappings = expected; |
| 123 | + const actual = new CppSettings(resource).clangTidyIncludeCleanerMappings; |
| 124 | + deepStrictEqual(actual, expected); |
| 125 | + strictEqual(Object.getPrototypeOf(actual), Object.prototype); |
| 126 | + strictEqual(Object.getOwnPropertyDescriptor(actual, '__proto__')?.value, '<prototype.h>'); |
| 127 | + strictEqual(requestedResource, resource); |
| 128 | + const pattern = new RegExp(schema.propertyNames.pattern); |
| 129 | + for (const symbol of Object.keys(expected)) { |
| 130 | + strictEqual(pattern.test(symbol), true, JSON.stringify(symbol)); |
| 131 | + } |
| 132 | + }); |
| 133 | + |
| 134 | + it('drops empty or control-containing symbol names without dropping valid entries', () => { |
| 135 | + const invalidSymbols = ['', ...controlCharacters.flatMap(control => [ |
| 136 | + control, `${control}example::widget`, `example::${control}widget`, `example::widget${control}` |
| 137 | + ])]; |
| 138 | + for (const symbol of invalidSymbols) { |
| 139 | + mappings = Object.freeze({ [symbol]: '<widget.h>', 'example::other': '<other.h>' }); |
| 140 | + deepStrictEqual(new CppSettings().clangTidyIncludeCleanerMappings, { 'example::other': '<other.h>' }, JSON.stringify(symbol)); |
| 141 | + strictEqual(new RegExp(schema.propertyNames.pattern).test(symbol), false, JSON.stringify(symbol)); |
| 142 | + } |
| 143 | + }); |
| 144 | + |
| 145 | + it('agrees with the schema on valid literal headers', () => { |
| 146 | + const pattern = new RegExp(schema.additionalProperties.pattern); |
| 147 | + for (const header of validHeaders) { |
| 148 | + mappings = { 'example::widget': header }; |
| 149 | + deepStrictEqual(new CppSettings().clangTidyIncludeCleanerMappings, mappings, JSON.stringify(header)); |
| 150 | + strictEqual(pattern.test(header), true, JSON.stringify(header)); |
| 151 | + } |
| 152 | + }); |
| 153 | + |
| 154 | + it('drops malformed headers without dropping valid string entries', () => { |
| 155 | + const pattern = new RegExp(schema.additionalProperties.pattern); |
| 156 | + for (const header of invalidHeaders) { |
| 157 | + mappings = Object.freeze({ 'example::widget': header, 'example::other': '<other.h>' }); |
| 158 | + deepStrictEqual(new CppSettings().clangTidyIncludeCleanerMappings, { 'example::other': '<other.h>' }, JSON.stringify(header)); |
| 159 | + strictEqual(pattern.test(header), false, JSON.stringify(header)); |
| 160 | + } |
| 161 | + }); |
| 162 | + |
| 163 | + it('uses the empty default for values that are not string-to-string objects', () => { |
| 164 | + for (const value of [null, false, 42, '<widget.h>', [], ['<widget.h>'], { 'example::widget': null }, |
| 165 | + { 'example::widget': ['<widget.h>'] }, { 'example::widget': 42, 'example::other': '<other.h>' }]) { |
| 166 | + mappings = value; |
| 167 | + deepStrictEqual(new CppSettings().clangTidyIncludeCleanerMappings, {}, JSON.stringify(value)); |
| 168 | + } |
| 169 | + }); |
| 170 | + |
| 171 | + it('redacts mapping keys and values from initial and changed-settings telemetry', () => { |
| 172 | + mappings = { 'private::widget': '<private-widget.h>', toString: '"private-string.h"' }; |
| 173 | + const tracker = new SettingsTracker(undefined); |
| 174 | + deepStrictEqual(tracker.getUserModifiedSettings(), { [settingName]: '...' }); |
| 175 | + deepStrictEqual(tracker.getChangedSettings(), {}); |
| 176 | + mappings = { 'private::other': '"private-other.h"' }; |
| 177 | + deepStrictEqual(tracker.getChangedSettings(), { [settingName]: '...' }); |
| 178 | + mappings = {}; |
| 179 | + deepStrictEqual(tracker.getChangedSettings(), { [settingName]: '<default>' }); |
| 180 | + }); |
| 181 | + |
| 182 | + it('redacts malformed mapping entries without recursing into their keys', () => { |
| 183 | + for (const value of [{ 'private::widget': { 'private-key': 'private-value' } }, ['private-value']]) { |
| 184 | + mappings = value; |
| 185 | + const tracker = new SettingsTracker(undefined); |
| 186 | + deepStrictEqual(tracker.getUserModifiedSettings(), { [settingName]: '...' }); |
| 187 | + } |
| 188 | + }); |
| 189 | +}); |
0 commit comments