Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/DiagnosticMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
};

Expand Down
67 changes: 66 additions & 1 deletion src/bscPlugin/validation/XmlFileValidator.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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
}
Expand Down Expand Up @@ -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
});
}
}

}
124 changes: 124 additions & 0 deletions src/files/XmlFile.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<Children>
<Label id="test" />
</Children>
</component>
`);
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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<Interface>
<field id="test" type="string" />
</Interface>
</component>
`);
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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<Script type="text/brightscript">
sub init()
end sub
</Script>
</component>
`);
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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<interface>
<Field id="test" type="string" />
</interface>
</component>
`);
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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<interface>
<field id="test" type="string" />
</interface>
<script type="text/brightscript">
sub init()
end sub
</script>
<children>
<Label id="test" />
</children>
</component>
`);
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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<children>
<Label id="test" />
</children>
</component>
`);
program.validate();
expectDiagnostics(program, [
{
...DiagnosticMessages.xmlTagCaseMismatch('Children', 'children'),
range: Range.create(2, 5, 2, 13)
}
]);
});
});
});
40 changes: 40 additions & 0 deletions src/parser/SGParser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<Children>
<Label id="test" />
</Children>
</component>
`);
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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<Interface>
<field id="test" type="string" />
</Interface>
</component>
`);
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`
<?xml version="1.0" encoding="utf-8" ?>
<component name="ChildScene" extends="ParentScene">
<Script type="text/brightscript" uri="./test.brs" />
</component>
`);
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(
Expand Down
25 changes: 19 additions & 6 deletions src/parser/SGParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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...
Expand Down