Skip to content

Commit 9a30994

Browse files
authored
Add configurable clang-tidy Include Cleaner provider mappings (#14788)
1 parent fbdb130 commit 9a30994

6 files changed

Lines changed: 221 additions & 0 deletions

File tree

‎Extension/package.json‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2287,6 +2287,20 @@
22872287
"markdownDescription": "%c_cpp.configuration.codeAnalysis.clangTidy.headerFilter.markdownDescription%",
22882288
"scope": "resource"
22892289
},
2290+
"C_Cpp.codeAnalysis.clangTidy.includeCleanerMappings": {
2291+
"type": "object",
2292+
"default": {},
2293+
"propertyNames": {
2294+
"minLength": 1,
2295+
"pattern": "^[^\\u0000-\\u001f\\u007f-\\u009f]+(?![\\s\\S])"
2296+
},
2297+
"additionalProperties": {
2298+
"type": "string",
2299+
"pattern": "^(?:<[^<>\"\\u0000-\\u001f\\u007f-\\u009f]+>|\"[^<>\"\\u0000-\\u001f\\u007f-\\u009f]+\")(?![\\s\\S])"
2300+
},
2301+
"markdownDescription": "%c_cpp.configuration.codeAnalysis.clangTidy.includeCleanerMappings.markdownDescription%",
2302+
"scope": "resource"
2303+
},
22902304
"C_Cpp.codeAnalysis.clangTidy.args": {
22912305
"type": "array",
22922306
"items": {

‎Extension/package.nls.json‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,12 @@
201201
"Markdown text between `` should not be translated or localized (they represent literal text) and the capitalization, spacing, and punctuation (including the ``) should not be altered."
202202
]
203203
},
204+
"c_cpp.configuration.codeAnalysis.clangTidy.includeCleanerMappings.markdownDescription": {
205+
"message": "Opt-in mappings from exact `misc-include-cleaner` symbol names to public headers. Keys must be nonempty. Values use `<path>` or `\"path\"` with a nonempty path and no embedded delimiters. Keys and values cannot contain control characters. Diagnostics are kept unless a direct include of the mapped header is proven; public-header insertion is offered when available.",
206+
"comment": [
207+
"Markdown text between `` should not be translated or localized (they represent literal text) and the capitalization, spacing, and punctuation (including the ``) should not be altered."
208+
]
209+
},
204210
"c_cpp.configuration.codeAnalysis.clangTidy.args.markdownDescription": {
205211
"message": "Additional command line arguments to pass to `clang-tidy`. These take precedence over the equivalent `C_Cpp.codeAnalysis.clangTidy.*` settings.",
206212
"comment": [

‎Extension/src/LanguageServer/client.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1498,6 +1498,7 @@ export class DefaultClient implements Client {
14981498
clangTidyConfig: settings.clangTidyConfig,
14991499
clangTidyFallbackConfig: settings.clangTidyFallbackConfig,
15001500
clangTidyHeaderFilter: settings.clangTidyHeaderFilter !== null ? util.resolveVariables(settings.clangTidyHeaderFilter, this.AdditionalEnvironment) : null,
1501+
clangTidyIncludeCleanerMappings: settings.clangTidyIncludeCleanerMappings,
15011502
clangTidyArgs: util.resolveVariablesArray(settings.clangTidyArgs, this.AdditionalEnvironment),
15021503
clangTidyUseBuildPath: settings.clangTidyUseBuildPath,
15031504
clangTidyChecksEnabled: settings.clangTidyChecksEnabled,

‎Extension/src/LanguageServer/settings.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export interface WorkspaceFolderSettingsParams {
6565
clangTidyConfig: string | undefined;
6666
clangTidyFallbackConfig: string | undefined;
6767
clangTidyHeaderFilter: string | null;
68+
clangTidyIncludeCleanerMappings: Associations;
6869
clangTidyArgs: string[];
6970
clangTidyUseBuildPath: boolean;
7071
clangTidyChecksEnabled: string[] | undefined;
@@ -349,6 +350,15 @@ export class CppSettings extends Settings {
349350
public get clangTidyConfig(): string | undefined { return changeBlankStringToUndefined(this.getAsStringOrUndefined("codeAnalysis.clangTidy.config")); }
350351
public get clangTidyFallbackConfig(): string | undefined { return changeBlankStringToUndefined(this.getAsStringOrUndefined("codeAnalysis.clangTidy.fallbackConfig")); }
351352
public get clangTidyHeaderFilter(): string | null { return this.getAsString("codeAnalysis.clangTidy.headerFilter", true); }
353+
public get clangTidyIncludeCleanerMappings(): Associations {
354+
const mappings: Associations = this.getAsAssociations("codeAnalysis.clangTidy.includeCleanerMappings");
355+
return Object.fromEntries(Object.entries(mappings).filter(([symbol, header]) =>
356+
symbol.length > 0
357+
&& !/[\u0000-\u001f\u007f-\u009f]/.test(symbol)
358+
&& header.length > 2
359+
&& ((header.startsWith("<") && header.endsWith(">")) || (header.startsWith('"') && header.endsWith('"')))
360+
&& !/[<>"\u0000-\u001f\u007f-\u009f]/.test(header.slice(1, -1))));
361+
}
352362
public get clangTidyArgs(): string[] | undefined { return this.getAsArrayOfStringsOrUndefined("codeAnalysis.clangTidy.args"); }
353363
public get clangTidyUseBuildPath(): boolean { return this.getAsBoolean("codeAnalysis.clangTidy.useBuildPath"); }
354364
public get clangTidyChecksEnabled(): string[] | undefined { return this.getAsArrayOfStringsOrUndefined("codeAnalysis.clangTidy.checks.enabled", true); }

‎Extension/src/LanguageServer/settingsTracker.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ export class SettingsTracker {
198198
|| key === "codeAnalysis.clangTidy.path"
199199
|| key === "codeAnalysis.clangTidy.headerFilter" || key === "codeAnalysis.clangTidy.args"
200200
|| key === "codeAnalysis.clangTidy.config" || key === "codeAnalysis.clangTidy.fallbackConfig"
201+
|| key === "codeAnalysis.clangTidy.includeCleanerMappings"
201202

202203
// Note: An existing bug prevents these settings of type "object" from getting processed here,
203204
// so these checks are here just in case that bug gets fixed later on.
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
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

Comments
 (0)