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` + + +