diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts
index 7f9f3f975..918519c85 100644
--- a/src/DiagnosticMessages.ts
+++ b/src/DiagnosticMessages.ts
@@ -747,6 +747,11 @@ export let DiagnosticMessages = {
message: `Non-void ${functionType} must return a value`,
code: 1142,
severity: DiagnosticSeverity.Error
+ }),
+ xmlTagCaseMismatch: (tagName: string, expectedTagName: string) => ({
+ message: `Tag '${tagName}' must be all lower case. Use '${expectedTagName}' instead.`,
+ code: 1143,
+ severity: DiagnosticSeverity.Error
})
};
diff --git a/src/bscPlugin/validation/XmlFileValidator.ts b/src/bscPlugin/validation/XmlFileValidator.ts
index 44912dced..5cf5f7247 100644
--- a/src/bscPlugin/validation/XmlFileValidator.ts
+++ b/src/bscPlugin/validation/XmlFileValidator.ts
@@ -1,7 +1,7 @@
import { DiagnosticMessages } from '../../DiagnosticMessages';
import type { XmlFile } from '../../files/XmlFile';
import type { OnFileValidateEvent } from '../../interfaces';
-import type { SGAst } from '../../parser/SGTypes';
+import type { SGAst, SGComponent, SGInterface } from '../../parser/SGTypes';
import util from '../../util';
export class XmlFileValidator {
@@ -14,6 +14,7 @@ export class XmlFileValidator {
util.validateTooDeepFile(this.event.file);
if (this.event.file.parser.ast.root) {
this.validateComponent(this.event.file.parser.ast);
+ this.validateTagCasing(this.event.file.parser.ast);
} else {
//skip empty XML
}
@@ -62,4 +63,68 @@ export class XmlFileValidator {
}
}
+ private validateTagCasing(ast: SGAst) {
+ const { component } = ast;
+ if (!component) {
+ return;
+ }
+
+ this.validateComponentTagCasing(component);
+ }
+
+ private validateComponentTagCasing(component: SGComponent) {
+ // Validate component-level tags
+ const componentLevelTags = ['children', 'interface', 'script', 'customization'];
+
+ // Check interface tag
+ if (component.api) {
+ this.validateTagNameCasing(component.api.tag, componentLevelTags);
+ this.validateInterfaceTagCasing(component.api);
+ }
+
+ // Check script tags
+ for (const script of component.scripts) {
+ this.validateTagNameCasing(script.tag, componentLevelTags);
+ }
+
+ // Check children tag
+ if (component.children) {
+ this.validateTagNameCasing(component.children.tag, componentLevelTags);
+ }
+
+ // Check customization tags
+ for (const customization of component.customizations) {
+ this.validateTagNameCasing(customization.tag, componentLevelTags);
+ }
+ }
+
+ private validateInterfaceTagCasing(interfaceTag: SGInterface) {
+ const interfaceLevelTags = ['field', 'function'];
+
+ // Check field tags
+ for (const field of interfaceTag.fields) {
+ this.validateTagNameCasing(field.tag, interfaceLevelTags);
+ }
+
+ // Check function tags
+ for (const func of interfaceTag.functions) {
+ this.validateTagNameCasing(func.tag, interfaceLevelTags);
+ }
+ }
+
+ private validateTagNameCasing(tag: { text: string; range?: any }, allowedTags: string[]) {
+ const tagName = tag.text;
+ const lowerCaseTag = tagName.toLowerCase();
+ const matchingAllowedTag = allowedTags.find(allowedTag => allowedTag.toLowerCase() === lowerCaseTag);
+
+ if (matchingAllowedTag && matchingAllowedTag !== tagName) {
+ // Case mismatch for a known tag
+ this.event.file.diagnostics.push({
+ ...DiagnosticMessages.xmlTagCaseMismatch(tagName, matchingAllowedTag),
+ range: tag.range,
+ file: this.event.file
+ });
+ }
+ }
+
}
diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts
index e8726a7e8..1d1f91bc4 100644
--- a/src/files/XmlFile.spec.ts
+++ b/src/files/XmlFile.spec.ts
@@ -1324,4 +1324,128 @@ describe('XmlFile', () => {
expect(program.getComponent('comp1')!.file.pkgPath).to.equal(comp2.pkgPath);
});
});
+
+ describe('XML tag casing validation', () => {
+ it('Adds error when incorrect casing is used for children tag', () => {
+ file = program.setFile('components/ChildScene.xml', trim`
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ {
+ ...DiagnosticMessages.xmlTagCaseMismatch('Children', 'children'),
+ range: Range.create(2, 5, 2, 13)
+ }
+ ]);
+ });
+
+ it('Adds error when incorrect casing is used for interface tag', () => {
+ file = program.setFile('components/ChildScene.xml', trim`
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ {
+ ...DiagnosticMessages.xmlTagCaseMismatch('Interface', 'interface'),
+ range: Range.create(2, 5, 2, 14)
+ }
+ ]);
+ });
+
+ it('Adds error when incorrect casing is used for script tag', () => {
+ file = program.setFile('components/ChildScene.xml', trim`
+
+
+
+
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ {
+ ...DiagnosticMessages.xmlTagCaseMismatch('Script', 'script'),
+ range: Range.create(2, 5, 2, 11)
+ }
+ ]);
+ });
+
+ it('Adds error when incorrect casing is used for field tag in interface', () => {
+ file = program.setFile('components/ChildScene.xml', trim`
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ {
+ ...DiagnosticMessages.xmlTagCaseMismatch('Field', 'field'),
+ range: Range.create(3, 9, 3, 14)
+ }
+ ]);
+ });
+
+ it('Does not add error for correctly cased tags', () => {
+ file = program.setFile('components/ChildScene.xml', trim`
+
+
+
+
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectZeroDiagnostics(program);
+ });
+
+ it('Catches casing issues when plugins modify AST after parsing', () => {
+ // This is the test requested in the comment - plugins modify tag casing and validation catches it
+ program.plugins.add({
+ name: 'test-plugin-modify-casing',
+ afterFileParse: (file) => {
+ if (isXmlFile(file) && file.parser.ast.component?.children) {
+ // Plugin modifies the children tag to incorrect casing
+ file.parser.ast.component.children.tag.text = 'Children';
+ }
+ }
+ });
+
+ file = program.setFile('components/ChildScene.xml', trim`
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectDiagnostics(program, [
+ {
+ ...DiagnosticMessages.xmlTagCaseMismatch('Children', 'children'),
+ range: Range.create(2, 5, 2, 13)
+ }
+ ]);
+ });
+ });
});
diff --git a/src/parser/SGParser.spec.ts b/src/parser/SGParser.spec.ts
index 22f7f451e..2ef9060c8 100644
--- a/src/parser/SGParser.spec.ts
+++ b/src/parser/SGParser.spec.ts
@@ -113,6 +113,46 @@ describe('SGParser', () => {
});
});
+ it('Does not add case mismatch error during parsing (now handled in validation)', () => {
+ const parser = new SGParser();
+ parser.parse(
+ 'pkg:/components/ParentScene.xml', trim`
+
+
+
+
+
+
+ `);
+ expect(parser.diagnostics).to.be.lengthOf(0);
+ });
+
+ it('Does not add case mismatch error during parsing for interface tag (now handled in validation)', () => {
+ const parser = new SGParser();
+ parser.parse(
+ 'pkg:/components/ParentScene.xml', trim`
+
+
+
+
+
+
+ `);
+ expect(parser.diagnostics).to.be.lengthOf(0);
+ });
+
+ it('Does not add case mismatch error during parsing for script tag (now handled in validation)', () => {
+ const parser = new SGParser();
+ parser.parse(
+ 'pkg:/components/ParentScene.xml', trim`
+
+
+
+
+ `);
+ expect(parser.diagnostics).to.be.lengthOf(0);
+ });
+
it('Adds error when a leaf tag is found to have children', () => {
const parser = new SGParser();
parser.parse(
diff --git a/src/parser/SGParser.ts b/src/parser/SGParser.ts
index bf2b5a6ce..f2b170eae 100644
--- a/src/parser/SGParser.ts
+++ b/src/parser/SGParser.ts
@@ -187,7 +187,10 @@ function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SG
const name = mapToken(nameToken);
const attributes = mapAttributes(children.attribute);
const content = children.content?.[0];
- switch (name.text) {
+
+ // Use case-insensitive matching to handle incorrect casing
+ const lowerCaseName = name.text.toLowerCase();
+ switch (lowerCaseName) {
case 'component':
const componentContent = mapElements(content, ['interface', 'script', 'children', 'customization'], diagnostics);
return new SGComponent(name, attributes, componentContent, range);
@@ -254,14 +257,24 @@ function mapElements(content: ContentCstNode, allow: string[], diagnostics: Diag
for (const entry of element) {
const name = entry.children.Name?.[0];
if (name?.image) {
+ // First check if it's exactly allowed
if (allow.includes(name.image)) {
tags.push(mapElement(entry, diagnostics));
} else {
- //unexpected tag
- diagnostics.push({
- ...DiagnosticMessages.xmlUnexpectedTag(name.image),
- range: rangeFromTokens(name)
- });
+ // Check if this is a case mismatch for a known tag
+ const lowerCaseTag = name.image.toLowerCase();
+ const matchingAllowedTag = allow.find(allowedTag => allowedTag.toLowerCase() === lowerCaseTag);
+
+ if (matchingAllowedTag) {
+ // Case mismatch for a known tag - create the AST object but validation will catch the casing issue
+ tags.push(mapElement(entry, diagnostics));
+ } else {
+ // Truly unexpected tag
+ diagnostics.push({
+ ...DiagnosticMessages.xmlUnexpectedTag(name.image),
+ range: rangeFromTokens(name)
+ });
+ }
}
} else {
//bad xml syntax...