Skip to content

Commit 832202f

Browse files
Map key order (#839)
* Validation for map key order Adds a new validation that checks if the keys of a map are in alphabetical order. Updates the tests to cover the new validation. * Add settings for keyOrdering Adds a setting to control keyOrdering feature. Tests if the new setting works as expected. Starts using the new setting to enable the feature. * add CodeAction to fix map order Adds a new codeaction that fixes the map order by rewriting the ast with the alphabetical order of keys. * Update docs for keyOrdering Updates readme and changelog with the information for the new feature. * Preserve comments Adds tests and updates the logic to preserve comments and spaces. --------- Co-authored-by: Muthurajan Sivasubramanian <[email protected]>
1 parent 64e3815 commit 832202f

14 files changed

+426
-6
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
### [UNRELEASED]
2+
3+
- Add: Enforce alphabetical ordering of keys in mappings and provide codeaction to fix it.
4+
15
### 1.11.0
26
- Fix: only the first choice is shown when hovering anyOf-typed properties [#784](https://github.com/redhat-developer/vscode-yaml/issues/784)
37
- Fix: Description in the schema root does not get displayed [#809](https://github.com/redhat-developer/vscode-yaml/issues/809)

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ The following settings are supported:
5252
- `yaml.suggest.parentSkeletonSelectedFirst`: If true, the user must select some parent skeleton first before autocompletion starts to suggest the rest of the properties.\nWhen yaml object is not empty, autocompletion ignores this setting and returns all properties and skeletons.
5353
- `yaml.style.flowMapping` : Forbids flow style mappings if set to `forbid`
5454
- `yaml.style.flowSequence` : Forbids flow style sequences if set to `forbid`
55+
- `yaml.keyOrdering` : Enforces alphabetical ordering of keys in mappings when set to `true`. Default is `false`
5556

5657
##### Adding custom tags
5758

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
},
3535
"dependencies": {
3636
"ajv": "^8.11.0",
37+
"lodash": "4.17.21",
3738
"request-light": "^0.5.7",
3839
"vscode-json-languageservice": "4.1.8",
3940
"vscode-languageserver": "^7.0.0",

src/languageserver/handlers/settingsHandlers.ts

+2
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export class SettingsHandler {
122122
flowMapping: settings.yaml.style?.flowMapping ?? 'allow',
123123
flowSequence: settings.yaml.style?.flowSequence ?? 'allow',
124124
};
125+
this.yamlSettings.keyOrdering = settings.yaml.keyOrdering ?? false;
125126
}
126127

127128
this.yamlSettings.schemaConfigurationSettings = [];
@@ -257,6 +258,7 @@ export class SettingsHandler {
257258
flowMapping: this.yamlSettings.style?.flowMapping,
258259
flowSequence: this.yamlSettings.style?.flowSequence,
259260
yamlVersion: this.yamlSettings.yamlVersion,
261+
keyOrdering: this.yamlSettings.keyOrdering,
260262
};
261263

262264
if (this.yamlSettings.schemaAssociations) {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Red Hat. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { TextDocument } from 'vscode-languageserver-textdocument';
7+
import { Diagnostic, DiagnosticSeverity, Range } from 'vscode-languageserver-types';
8+
import { SingleYAMLDocument } from '../../parser/yaml-documents';
9+
import { AdditionalValidator } from './types';
10+
import { isMap, Pair, visit } from 'yaml';
11+
12+
export class MapKeyOrderValidator implements AdditionalValidator {
13+
validate(document: TextDocument, yamlDoc: SingleYAMLDocument): Diagnostic[] {
14+
const result = [];
15+
16+
visit(yamlDoc.internalDocument, (key, node) => {
17+
if (isMap(node)) {
18+
for (let i = 1; i < node.items.length; i++) {
19+
if (compare(node.items[i - 1], node.items[i]) > 0) {
20+
const range = createRange(document, node.items[i - 1]);
21+
result.push(
22+
Diagnostic.create(
23+
range,
24+
`Wrong ordering of key "${node.items[i - 1].key}" in mapping`,
25+
DiagnosticSeverity.Error,
26+
'mapKeyOrder'
27+
)
28+
);
29+
}
30+
}
31+
}
32+
});
33+
34+
return result;
35+
}
36+
}
37+
38+
function createRange(document: TextDocument, node: Pair): Range {
39+
const start = node?.srcToken.start[0]?.offset ?? node?.srcToken?.key.offset ?? node?.srcToken?.sep[0]?.offset;
40+
const end =
41+
node?.srcToken?.value.offset ||
42+
node?.srcToken?.sep[0]?.offset ||
43+
node?.srcToken?.key.offset ||
44+
node?.srcToken.start[node.srcToken.start.length - 1]?.offset;
45+
return Range.create(document.positionAt(start), document.positionAt(end));
46+
}
47+
48+
function compare(thiz: Pair, that: Pair): number {
49+
const thatKey = String(that.key);
50+
const thisKey = String(thiz.key);
51+
return thisKey.localeCompare(thatKey);
52+
}

src/languageservice/services/yamlCodeActions.ts

+89-5
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,12 @@ import { LanguageSettings } from '../yamlLanguageService';
2222
import { YAML_SOURCE } from '../parser/jsonParser07';
2323
import { getFirstNonWhitespaceCharacterAfterOffset } from '../utils/strings';
2424
import { matchOffsetToDocument } from '../utils/arrUtils';
25-
import { isMap, isSeq } from 'yaml';
25+
import { CST, isMap, isSeq, YAMLMap } from 'yaml';
2626
import { yamlDocumentsCache } from '../parser/yaml-documents';
2727
import { FlowStyleRewriter } from '../utils/flow-style-rewriter';
28+
import { ASTNode } from '../jsonASTTypes';
29+
import * as _ from 'lodash';
30+
import { SourceToken } from 'yaml/dist/parse/cst';
2831

2932
interface YamlDiagnosticData {
3033
schemaUri: string[];
@@ -50,6 +53,7 @@ export class YamlCodeActions {
5053
result.push(...this.getTabToSpaceConverting(params.context.diagnostics, document));
5154
result.push(...this.getUnusedAnchorsDelete(params.context.diagnostics, document));
5255
result.push(...this.getConvertToBlockStyleActions(params.context.diagnostics, document));
56+
result.push(...this.getKeyOrderActions(params.context.diagnostics, document));
5357

5458
return result;
5559
}
@@ -217,10 +221,7 @@ export class YamlCodeActions {
217221
const results: CodeAction[] = [];
218222
for (const diagnostic of diagnostics) {
219223
if (diagnostic.code === 'flowMap' || diagnostic.code === 'flowSeq') {
220-
const yamlDocuments = yamlDocumentsCache.getYamlDocument(document);
221-
const startOffset = document.offsetAt(diagnostic.range.start);
222-
const yamlDoc = matchOffsetToDocument(startOffset, yamlDocuments);
223-
const node = yamlDoc.getNodeFromOffset(startOffset);
224+
const node = getNodeforDiagnostic(document, diagnostic);
224225
if (isMap(node.internalNode) || isSeq(node.internalNode)) {
225226
const blockTypeDescription = isMap(node.internalNode) ? 'map' : 'sequence';
226227
const rewriter = new FlowStyleRewriter(this.indentation);
@@ -236,6 +237,89 @@ export class YamlCodeActions {
236237
}
237238
return results;
238239
}
240+
241+
private getKeyOrderActions(diagnostics: Diagnostic[], document: TextDocument): CodeAction[] {
242+
const results: CodeAction[] = [];
243+
for (const diagnostic of diagnostics) {
244+
if (diagnostic?.code === 'mapKeyOrder') {
245+
let node = getNodeforDiagnostic(document, diagnostic);
246+
while (node && node.type !== 'object') {
247+
node = node.parent;
248+
}
249+
if (node && isMap(node.internalNode)) {
250+
const sorted: YAMLMap = _.cloneDeep(node.internalNode);
251+
if (
252+
(sorted.srcToken.type === 'block-map' || sorted.srcToken.type === 'flow-collection') &&
253+
(node.internalNode.srcToken.type === 'block-map' || node.internalNode.srcToken.type === 'flow-collection')
254+
) {
255+
sorted.srcToken.items.sort((a, b) => {
256+
if (a.key && b.key && CST.isScalar(a.key) && CST.isScalar(b.key)) {
257+
return a.key.source.localeCompare(b.key.source);
258+
}
259+
if (!a.key && b.key) {
260+
return -1;
261+
}
262+
if (a.key && !b.key) {
263+
return 1;
264+
}
265+
if (!a.key && !b.key) {
266+
return 0;
267+
}
268+
});
269+
270+
for (let i = 0; i < sorted.srcToken.items.length; i++) {
271+
const item = sorted.srcToken.items[i];
272+
const uItem = node.internalNode.srcToken.items[i];
273+
item.start = uItem.start;
274+
if (
275+
item.value?.type === 'alias' ||
276+
item.value?.type === 'scalar' ||
277+
item.value?.type === 'single-quoted-scalar' ||
278+
item.value?.type === 'double-quoted-scalar'
279+
) {
280+
const newLineIndex = item.value?.end?.findIndex((p) => p.type === 'newline') ?? -1;
281+
let newLineToken = null;
282+
if (uItem.value?.type === 'block-scalar') {
283+
newLineToken = uItem.value?.props?.find((p) => p.type === 'newline');
284+
} else if (CST.isScalar(uItem.value)) {
285+
newLineToken = uItem.value?.end?.find((p) => p.type === 'newline');
286+
}
287+
if (newLineToken && newLineIndex < 0) {
288+
item.value.end = item.value.end ?? [];
289+
item.value.end.push(newLineToken as SourceToken);
290+
}
291+
if (!newLineToken && newLineIndex > -1) {
292+
item.value.end.splice(newLineIndex, 1);
293+
}
294+
} else if (item.value?.type === 'block-scalar') {
295+
const nwline = item.value.props.find((p) => p.type === 'newline');
296+
if (!nwline) {
297+
item.value.props.push({ type: 'newline', indent: 0, offset: item.value.offset, source: '\n' } as SourceToken);
298+
}
299+
}
300+
}
301+
}
302+
const replaceRange = Range.create(document.positionAt(node.offset), document.positionAt(node.offset + node.length));
303+
results.push(
304+
CodeAction.create(
305+
'Fix key order for this map',
306+
createWorkspaceEdit(document.uri, [TextEdit.replace(replaceRange, CST.stringify(sorted.srcToken))]),
307+
CodeActionKind.QuickFix
308+
)
309+
);
310+
}
311+
}
312+
}
313+
return results;
314+
}
315+
}
316+
317+
function getNodeforDiagnostic(document: TextDocument, diagnostic: Diagnostic): ASTNode {
318+
const yamlDocuments = yamlDocumentsCache.getYamlDocument(document);
319+
const startOffset = document.offsetAt(diagnostic.range.start);
320+
const yamlDoc = matchOffsetToDocument(startOffset, yamlDocuments);
321+
const node = yamlDoc.getNodeFromOffset(startOffset);
322+
return node;
239323
}
240324

241325
function createWorkspaceEdit(uri: string, edits: TextEdit[]): WorkspaceEdit {

src/languageservice/services/yamlValidation.ts

+4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { Telemetry } from '../telemetry';
2020
import { AdditionalValidator } from './validation/types';
2121
import { UnusedAnchorsValidator } from './validation/unused-anchors';
2222
import { YAMLStyleValidator } from './validation/yaml-style';
23+
import { MapKeyOrderValidator } from './validation/map-key-order';
2324

2425
/**
2526
* Convert a YAMLDocDiagnostic to a language server Diagnostic
@@ -64,6 +65,9 @@ export class YAMLValidation {
6465
if (settings.flowMapping === 'forbid' || settings.flowSequence === 'forbid') {
6566
this.validators.push(new YAMLStyleValidator(settings));
6667
}
68+
if (settings.keyOrdering) {
69+
this.validators.push(new MapKeyOrderValidator());
70+
}
6771
}
6872
this.validators.push(new UnusedAnchorsValidator());
6973
}

src/languageservice/yamlLanguageService.ts

+4
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,10 @@ export interface LanguageSettings {
115115
* Control the use of flow sequences. Default is allow.
116116
*/
117117
flowSequence?: 'allow' | 'forbid';
118+
/**
119+
* If set enforce alphabetical ordering of keys in mappings.
120+
*/
121+
keyOrdering?: boolean;
118122
}
119123

120124
export interface WorkspaceContextService {

src/yamlSettings.ts

+2
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface Settings {
2929
flowMapping: 'allow' | 'forbid';
3030
flowSequence: 'allow' | 'forbid';
3131
};
32+
keyOrdering: boolean;
3233
maxItemsComputed: number;
3334
yamlVersion: YamlVersion;
3435
};
@@ -86,6 +87,7 @@ export class SettingsState {
8687
flowMapping: 'allow' | 'forbid';
8788
flowSequence: 'allow' | 'forbid';
8889
};
90+
keyOrdering = true;
8991
maxItemsComputed = 5000;
9092

9193
// File validation helpers

test/settingsHandlers.test.ts

+31
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,37 @@ describe('Settings Handlers Tests', () => {
110110
});
111111
});
112112

113+
describe('Settings for key ordering should ', () => {
114+
it(' reflect to the settings ', async () => {
115+
const settingsHandler = new SettingsHandler(
116+
connection,
117+
(languageService as unknown) as LanguageService,
118+
settingsState,
119+
(validationHandler as unknown) as ValidationHandler,
120+
{} as Telemetry
121+
);
122+
workspaceStub.getConfiguration.resolves([{ keyOrdering: true }, {}, {}, {}, {}]);
123+
124+
await settingsHandler.pullConfiguration();
125+
expect(settingsState.keyOrdering).to.exist;
126+
expect(settingsState.keyOrdering).to.be.true;
127+
});
128+
it(' reflect default values if no settings given', async () => {
129+
const settingsHandler = new SettingsHandler(
130+
connection,
131+
(languageService as unknown) as LanguageService,
132+
settingsState,
133+
(validationHandler as unknown) as ValidationHandler,
134+
{} as Telemetry
135+
);
136+
workspaceStub.getConfiguration.resolves([{}, {}, {}, {}, {}]);
137+
138+
await settingsHandler.pullConfiguration();
139+
expect(settingsState.style).to.exist;
140+
expect(settingsState.keyOrdering).to.be.false;
141+
});
142+
});
143+
113144
describe('Settings for file associations should ', () => {
114145
it('reflect to settings state', async () => {
115146
const settingsHandler = new SettingsHandler(

test/utils/serviceSetup.ts

+4
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,8 @@ export class ServiceSetup {
7171
this.languageSettings.flowSequence = sequence;
7272
return this;
7373
}
74+
withKeyOrdering(order = true): ServiceSetup {
75+
this.languageSettings.keyOrdering = order;
76+
return this;
77+
}
7478
}

0 commit comments

Comments
 (0)