From 26c15511ebe97a44708c775d8bcec1f43cadffb9 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 19:25:59 +0000
Subject: [PATCH 1/4] Initial plan
From c05a712d6a7c2041ed018b0648cd0200fbf1a2cd Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 19:36:50 +0000
Subject: [PATCH 2/4] Add diagnostic and fix transpile for mismatched XML tags
- Added xmlTagMismatch diagnostic message (code 1155)
- Modified SGParser to capture and validate closing tag names
- Updated SGTag and subclasses to store optional closingTag
- Added diagnostic when opening/closing tags mismatch
- Transpile always uses opening tag name for closing tag
- Added comprehensive tests for both diagnostic and transpile behavior
- All existing tests pass
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
---
src/DiagnosticMessages.ts | 5 +++
src/files/XmlFile.spec.ts | 67 +++++++++++++++++++++++++++++++++++++
src/parser/SGParser.spec.ts | 19 +++++++++++
src/parser/SGParser.ts | 50 +++++++++++++++++++--------
src/parser/SGTypes.ts | 38 ++++++++++++---------
5 files changed, 150 insertions(+), 29 deletions(-)
diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts
index cd81e60bc..2a7c3b0a1 100644
--- a/src/DiagnosticMessages.ts
+++ b/src/DiagnosticMessages.ts
@@ -382,6 +382,11 @@ export let DiagnosticMessages = {
code: 1070,
severity: DiagnosticSeverity.Error
}),
+ xmlTagMismatch: (openingTag: string, closingTag: string) => ({
+ message: `Mismatched closing tag: expected '${openingTag}>' but found '${closingTag}>'`,
+ code: 1155,
+ severity: DiagnosticSeverity.Error
+ }),
expectedLabelIdentifierAfterGotoKeyword: () => ({
message: `Expected label identifier after 'goto' keyword`,
code: 1071,
diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts
index 44d1700ee..9d6b0069d 100644
--- a/src/files/XmlFile.spec.ts
+++ b/src/files/XmlFile.spec.ts
@@ -669,6 +669,73 @@ describe('XmlFile', () => {
`, 'none', 'components/Comp.xml');
});
+ it('transpiles mismatched tags correctly using opening tag', () => {
+ const file = program.setFile('components/Comp.xml', trim`
+
+
+
+
+
+
+
+ `);
+ file.needsTranspiled = true;
+ program.validate();
+
+ // Should have a diagnostic for the mismatch
+ expect(file.diagnostics).to.have.lengthOf(1);
+ expect(file.diagnostics[0]).to.deep.include({
+ ...DiagnosticMessages.xmlTagMismatch('Group', 'LayoutGroup')
+ });
+
+ // But transpile should still work correctly (self-closing since no children)
+ const transpiled = file.transpile();
+ expect(trimMap(transpiled.code)).to.equal(trim`
+
+
+
+
+
+
+
+ `);
+ });
+
+ it('transpiles mismatched tags with children correctly using opening tag', () => {
+ const file = program.setFile('components/Comp.xml', trim`
+
+
+
+
+
+
+
+
+ `);
+ file.needsTranspiled = true;
+ program.validate();
+
+ // Should have a diagnostic for the mismatch
+ expect(file.diagnostics).to.have.lengthOf(1);
+ expect(file.diagnostics[0]).to.deep.include({
+ ...DiagnosticMessages.xmlTagMismatch('Group', 'LayoutGroup')
+ });
+
+ // Transpile should use the opening tag for closing, not the mismatched closing tag
+ const transpiled = file.transpile();
+ expect(trimMap(transpiled.code)).to.equal(trim`
+
+
+
+
+
+
+
+
+
+ `);
+ });
+
it('does not include additional bslib script if already there ', () => {
testTranspile(trim`
diff --git a/src/parser/SGParser.spec.ts b/src/parser/SGParser.spec.ts
index 22f7f451e..0924d40e4 100644
--- a/src/parser/SGParser.spec.ts
+++ b/src/parser/SGParser.spec.ts
@@ -158,4 +158,23 @@ describe('SGParser', () => {
range: Range.create(0, 0, 1, 12)
});
});
+
+ it('Adds error when tag names mismatch', () => {
+ const parser = new SGParser();
+ parser.parse(
+ 'pkg:/components/ParentScene.xml', trim`
+
+
+
+
+
+
+
+ `);
+ expect(parser.diagnostics).to.be.lengthOf(1);
+ expect(parser.diagnostics[0]).to.deep.include({
+ ...DiagnosticMessages.xmlTagMismatch('Group', 'LayoutGroup'),
+ range: Range.create(4, 10, 4, 21)
+ });
+ });
});
diff --git a/src/parser/SGParser.ts b/src/parser/SGParser.ts
index bf2b5a6ce..85391c05c 100644
--- a/src/parser/SGParser.ts
+++ b/src/parser/SGParser.ts
@@ -175,6 +175,7 @@ interface IToken {
function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SGTag {
const nameToken = children.Name[0];
+ const closingNameToken = children.END_NAME?.[0];
let range: Range;
const selfClosing = !!children.SLASH_CLOSE;
if (selfClosing) {
@@ -185,37 +186,47 @@ function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SG
range = rangeFromTokens(nameToken, endToken);
}
const name = mapToken(nameToken);
+ const closingName = closingNameToken ? mapToken(closingNameToken) : undefined;
+
+ // Check for tag mismatch
+ if (closingName && name.text !== closingName.text) {
+ diagnostics.push({
+ ...DiagnosticMessages.xmlTagMismatch(name.text, closingName.text),
+ range: closingName.range
+ });
+ }
+
const attributes = mapAttributes(children.attribute);
const content = children.content?.[0];
switch (name.text) {
case 'component':
const componentContent = mapElements(content, ['interface', 'script', 'children', 'customization'], diagnostics);
- return new SGComponent(name, attributes, componentContent, range);
+ return new SGComponent(name, attributes, componentContent, range, closingName);
case 'interface':
const interfaceContent = mapElements(content, ['field', 'function'], diagnostics);
- return new SGInterface(name, interfaceContent, range);
+ return new SGInterface(name, interfaceContent, range, closingName);
case 'field':
if (hasElements(content)) {
reportUnexpectedChildren(name, diagnostics);
}
- return new SGField(name, attributes, range);
+ return new SGField(name, attributes, range, closingName);
case 'function':
if (hasElements(content)) {
reportUnexpectedChildren(name, diagnostics);
}
- return new SGFunction(name, attributes, range);
+ return new SGFunction(name, attributes, range, closingName);
case 'script':
if (hasElements(content)) {
reportUnexpectedChildren(name, diagnostics);
}
const cdata = getCdata(content);
- return new SGScript(name, attributes, cdata, range);
+ return new SGScript(name, attributes, cdata, range, closingName);
case 'children':
- const childrenContent = mapNodes(content);
- return new SGChildren(name, childrenContent, range);
+ const childrenContent = mapNodes(content, diagnostics);
+ return new SGChildren(name, childrenContent, range, closingName);
default:
- const nodeContent = mapNodes(content);
- return new SGNode(name, attributes, nodeContent, range);
+ const nodeContent = mapNodes(content, diagnostics);
+ return new SGNode(name, attributes, nodeContent, range, closingName);
}
}
@@ -226,8 +237,9 @@ function reportUnexpectedChildren(name: SGToken, diagnostics: Diagnostic[]) {
});
}
-function mapNode({ children }: ElementCstNode): SGNode {
+function mapNode({ children }: ElementCstNode, diagnostics?: Diagnostic[]): SGNode {
const nameToken = children.Name[0];
+ const closingNameToken = children.END_NAME?.[0];
let range: Range;
const selfClosing = !!children.SLASH_CLOSE;
if (selfClosing) {
@@ -238,10 +250,20 @@ function mapNode({ children }: ElementCstNode): SGNode {
range = rangeFromTokens(nameToken, endToken);
}
const name = mapToken(nameToken);
+ const closingName = closingNameToken ? mapToken(closingNameToken) : undefined;
+
+ // Check for tag mismatch
+ if (closingName && name.text !== closingName.text && diagnostics) {
+ diagnostics.push({
+ ...DiagnosticMessages.xmlTagMismatch(name.text, closingName.text),
+ range: closingName.range
+ });
+ }
+
const attributes = mapAttributes(children.attribute);
const content = children.content?.[0];
- const nodeContent = mapNodes(content);
- return new SGNode(name, attributes, nodeContent, range);
+ const nodeContent = mapNodes(content, diagnostics);
+ return new SGNode(name, attributes, nodeContent, range, closingName);
}
function mapElements(content: ContentCstNode, allow: string[], diagnostics: Diagnostic[]): SGTag[] {
@@ -271,12 +293,12 @@ function mapElements(content: ContentCstNode, allow: string[], diagnostics: Diag
return tags;
}
-function mapNodes(content: ContentCstNode): SGNode[] {
+function mapNodes(content: ContentCstNode, diagnostics?: Diagnostic[]): SGNode[] {
if (!content) {
return [];
}
const { element } = content.children;
- return element?.map(element => mapNode(element));
+ return element?.map(element => mapNode(element, diagnostics)) ?? [];
}
function hasElements(content: ContentCstNode): boolean {
diff --git a/src/parser/SGTypes.ts b/src/parser/SGTypes.ts
index 07d960bcf..47220cc24 100644
--- a/src/parser/SGTypes.ts
+++ b/src/parser/SGTypes.ts
@@ -24,7 +24,8 @@ export class SGTag {
constructor(
public tag: SGToken,
public attributes: SGAttribute[] = [],
- public range?: Range
+ public range?: Range,
+ public closingTag?: SGToken
) { }
get id() {
@@ -106,9 +107,10 @@ export class SGNode extends SGTag {
tag: SGToken,
attributes?: SGAttribute[],
public children: SGNode[] = [],
- range?: Range
+ range?: Range,
+ closingTag?: SGToken
) {
- super(tag, attributes, range);
+ super(tag, attributes, range, closingTag);
}
protected transpileBody(state: TranspileState): (string | SourceNode)[] {
@@ -130,9 +132,10 @@ export class SGChildren extends SGNode {
constructor(
tag: SGToken = { text: 'children' },
children: SGNode[] = [],
- range?: Range
+ range?: Range,
+ closingTag?: SGToken
) {
- super(tag, [], children, range);
+ super(tag, [], children, range, closingTag);
}
}
@@ -142,9 +145,10 @@ export class SGScript extends SGTag {
tag: SGToken = { text: 'script' },
attributes?: SGAttribute[],
public cdata?: SGToken,
- range?: Range
+ range?: Range,
+ closingTag?: SGToken
) {
- super(tag, attributes, range);
+ super(tag, attributes, range, closingTag);
if (!attributes) {
this.type = 'text/brightscript';
}
@@ -216,9 +220,10 @@ export class SGField extends SGTag {
constructor(
tag: SGToken = { text: 'field' },
attributes: SGAttribute[] = [],
- range?: Range
+ range?: Range,
+ closingTag?: SGToken
) {
- super(tag, attributes, range);
+ super(tag, attributes, range, closingTag);
}
get type() {
@@ -269,9 +274,10 @@ export class SGFunction extends SGTag {
constructor(
tag: SGToken = { text: 'function' },
attributes: SGAttribute[] = [],
- range?: Range
+ range?: Range,
+ closingTag?: SGToken
) {
- super(tag, attributes, range);
+ super(tag, attributes, range, closingTag);
}
get name() {
@@ -290,9 +296,10 @@ export class SGInterface extends SGTag {
constructor(
tag: SGToken = { text: 'interface' },
content?: SGTag[],
- range?: Range
+ range?: Range,
+ closingTag?: SGToken
) {
- super(tag, [], range);
+ super(tag, [], range, closingTag);
if (content) {
for (const tag of content) {
if (isSGField(tag)) {
@@ -356,9 +363,10 @@ export class SGComponent extends SGTag {
tag: SGToken = { text: 'component' },
attributes?: SGAttribute[],
content?: SGTag[],
- range?: Range
+ range?: Range,
+ closingTag?: SGToken
) {
- super(tag, attributes, range);
+ super(tag, attributes, range, closingTag);
if (content) {
for (const tag of content) {
if (isSGInterface(tag)) {
From 94d0bc50261742cfe0ddb463bffd215878749fe5 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 7 Jul 2026 19:38:38 +0000
Subject: [PATCH 3/4] Fix lint issues (trailing spaces)
Co-authored-by: TwitchBronBron <2544493+TwitchBronBron@users.noreply.github.com>
---
src/files/XmlFile.spec.ts | 8 ++++----
src/parser/SGParser.ts | 8 ++++----
2 files changed, 8 insertions(+), 8 deletions(-)
diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts
index 9d6b0069d..02dcfd0b3 100644
--- a/src/files/XmlFile.spec.ts
+++ b/src/files/XmlFile.spec.ts
@@ -681,13 +681,13 @@ describe('XmlFile', () => {
`);
file.needsTranspiled = true;
program.validate();
-
+
// Should have a diagnostic for the mismatch
expect(file.diagnostics).to.have.lengthOf(1);
expect(file.diagnostics[0]).to.deep.include({
...DiagnosticMessages.xmlTagMismatch('Group', 'LayoutGroup')
});
-
+
// But transpile should still work correctly (self-closing since no children)
const transpiled = file.transpile();
expect(trimMap(transpiled.code)).to.equal(trim`
@@ -714,13 +714,13 @@ describe('XmlFile', () => {
`);
file.needsTranspiled = true;
program.validate();
-
+
// Should have a diagnostic for the mismatch
expect(file.diagnostics).to.have.lengthOf(1);
expect(file.diagnostics[0]).to.deep.include({
...DiagnosticMessages.xmlTagMismatch('Group', 'LayoutGroup')
});
-
+
// Transpile should use the opening tag for closing, not the mismatched closing tag
const transpiled = file.transpile();
expect(trimMap(transpiled.code)).to.equal(trim`
diff --git a/src/parser/SGParser.ts b/src/parser/SGParser.ts
index 85391c05c..2d8ab04a9 100644
--- a/src/parser/SGParser.ts
+++ b/src/parser/SGParser.ts
@@ -187,7 +187,7 @@ function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SG
}
const name = mapToken(nameToken);
const closingName = closingNameToken ? mapToken(closingNameToken) : undefined;
-
+
// Check for tag mismatch
if (closingName && name.text !== closingName.text) {
diagnostics.push({
@@ -195,7 +195,7 @@ function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SG
range: closingName.range
});
}
-
+
const attributes = mapAttributes(children.attribute);
const content = children.content?.[0];
switch (name.text) {
@@ -251,7 +251,7 @@ function mapNode({ children }: ElementCstNode, diagnostics?: Diagnostic[]): SGNo
}
const name = mapToken(nameToken);
const closingName = closingNameToken ? mapToken(closingNameToken) : undefined;
-
+
// Check for tag mismatch
if (closingName && name.text !== closingName.text && diagnostics) {
diagnostics.push({
@@ -259,7 +259,7 @@ function mapNode({ children }: ElementCstNode, diagnostics?: Diagnostic[]): SGNo
range: closingName.range
});
}
-
+
const attributes = mapAttributes(children.attribute);
const content = children.content?.[0];
const nodeContent = mapNodes(content, diagnostics);
From 156186c20a54ac10ef6caca2ef113d84e7ea7fd1 Mon Sep 17 00:00:00 2001
From: Bronley Plumb
Date: Fri, 10 Jul 2026 12:30:18 -0400
Subject: [PATCH 4/4] Move XML tag-mismatch check to validation time
Reworks the mismatched-closing-tag detection so it runs during validation
against the stored `closingTag` on the AST, rather than at parse time from raw
tokens. This means the diagnostic now fires for AST injected/mutated by plugins,
not just AST produced by the parser.
- Parser: keep capturing the closing tag name into `SGTag.closingTag`, but no
longer emit the diagnostic during parse (reverts the parse-time diagnostic and
the unnecessary `diagnostics` threading through mapNode/mapNodes).
- Validator: add `validateTagClosings`, which walks the SG tag tree via a new
`SGTag.getChildren()` and reports `xmlTagMismatch` when a present closingTag
does not match the opening tag. Self-closing / programmatically-built tags
(closingTag undefined) are left valid, preserving backwards compatibility.
- Move diagnostic 1155 to its correct sequential position in DiagnosticMessages.
- Add SGTypes.spec.ts proving every affected tag type behaves correctly when
constructed without a closingTag, and an XmlFile test proving a plugin-injected
mismatch is caught at validation time.
Note: transpile already emitted the corrected closing tag (`this.tag.text`) in
both current master and older v0, so no transpile fix was needed.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
src/DiagnosticMessages.ts | 10 +-
src/bscPlugin/validation/XmlFileValidator.ts | 25 ++++-
src/files/XmlFile.spec.ts | 57 ++++++++++++
src/parser/SGParser.spec.ts | 31 +++++--
src/parser/SGParser.ts | 28 ++----
src/parser/SGTypes.spec.ts | 98 ++++++++++++++++++++
src/parser/SGTypes.ts | 30 ++++++
7 files changed, 245 insertions(+), 34 deletions(-)
create mode 100644 src/parser/SGTypes.spec.ts
diff --git a/src/DiagnosticMessages.ts b/src/DiagnosticMessages.ts
index 2a7c3b0a1..a9012a97b 100644
--- a/src/DiagnosticMessages.ts
+++ b/src/DiagnosticMessages.ts
@@ -382,11 +382,6 @@ export let DiagnosticMessages = {
code: 1070,
severity: DiagnosticSeverity.Error
}),
- xmlTagMismatch: (openingTag: string, closingTag: string) => ({
- message: `Mismatched closing tag: expected '${openingTag}>' but found '${closingTag}>'`,
- code: 1155,
- severity: DiagnosticSeverity.Error
- }),
expectedLabelIdentifierAfterGotoKeyword: () => ({
message: `Expected label identifier after 'goto' keyword`,
code: 1071,
@@ -838,6 +833,11 @@ export let DiagnosticMessages = {
message: `rsg_version=${rsgVersion} was removed in firmware ${removedAt}; use rsg_version=${replacement}`,
code: 1154,
severity: DiagnosticSeverity.Error
+ }),
+ xmlTagMismatch: (openingTag: string, closingTag: string) => ({
+ message: `Mismatched closing tag: expected '${openingTag}>' but found '${closingTag}>'`,
+ code: 1155,
+ severity: DiagnosticSeverity.Error
})
};
diff --git a/src/bscPlugin/validation/XmlFileValidator.ts b/src/bscPlugin/validation/XmlFileValidator.ts
index 44912dced..d23a4eb31 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, SGTag } from '../../parser/SGTypes';
import util from '../../util';
export class XmlFileValidator {
@@ -14,11 +14,34 @@ export class XmlFileValidator {
util.validateTooDeepFile(this.event.file);
if (this.event.file.parser.ast.root) {
this.validateComponent(this.event.file.parser.ast);
+ this.validateTagClosings(this.event.file.parser.ast.root);
} else {
//skip empty XML
}
}
+ /**
+ * Walk the SG tag tree and report any tag whose closing tag name does not
+ * match its opening tag name (e.g. ``). This runs at
+ * validation time (rather than parse time) so it also catches mismatches in
+ * AST injected by plugins, not just AST produced by the parser.
+ */
+ private validateTagClosings(tag: SGTag) {
+ const closingTagText = tag.closingTag?.text;
+ //only validate when a closing tag was actually present (self-closing and
+ //programmatically-built tags omit it, and must remain valid)
+ if (closingTagText !== undefined && closingTagText !== tag.tag.text) {
+ this.event.file.diagnostics.push({
+ ...DiagnosticMessages.xmlTagMismatch(tag.tag.text, closingTagText),
+ range: tag.closingTag.range,
+ file: this.event.file
+ });
+ }
+ for (const child of tag.getChildren()) {
+ this.validateTagClosings(child);
+ }
+ }
+
private validateComponent(ast: SGAst) {
const { root, component } = ast;
if (!component) {
diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts
index 02dcfd0b3..134185acb 100644
--- a/src/files/XmlFile.spec.ts
+++ b/src/files/XmlFile.spec.ts
@@ -736,6 +736,63 @@ describe('XmlFile', () => {
`);
});
+ it('does not emit a mismatch diagnostic for well-formed matching tags', () => {
+ const file = program.setFile('components/Comp.xml', trim`
+
+
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectZeroDiagnostics(file);
+ });
+
+ it('does not emit a mismatch diagnostic for self-closing tags', () => {
+ const file = program.setFile('components/Comp.xml', trim`
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectZeroDiagnostics(file);
+ });
+
+ it('catches mismatched tags injected via a plugin (no closingTag on the parser AST)', () => {
+ //this proves the check runs at validation time against the stored closingTag,
+ //so it catches AST mutated/injected by plugins, not just parser output
+ program.plugins.add({
+ name: 'inject-mismatched-closing-tag',
+ afterFileParse: (file) => {
+ if (isXmlFile(file)) {
+ const group = file.parser.ast.component?.children?.children?.[0];
+ if (group) {
+ //plugin sets a mismatched closing tag programmatically
+ group.closingTag = { text: 'LayoutGroup' };
+ }
+ }
+ }
+ });
+ const file = program.setFile('components/Comp.xml', trim`
+
+
+
+
+
+
+ `);
+ program.validate();
+ expectDiagnostics(file, [
+ DiagnosticMessages.xmlTagMismatch('Group', 'LayoutGroup')
+ ]);
+ });
+
it('does not include additional bslib script if already there ', () => {
testTranspile(trim`
diff --git a/src/parser/SGParser.spec.ts b/src/parser/SGParser.spec.ts
index 0924d40e4..44d4efdb6 100644
--- a/src/parser/SGParser.spec.ts
+++ b/src/parser/SGParser.spec.ts
@@ -159,7 +159,7 @@ describe('SGParser', () => {
});
});
- it('Adds error when tag names mismatch', () => {
+ it('captures the closing tag name on the AST when it mismatches', () => {
const parser = new SGParser();
parser.parse(
'pkg:/components/ParentScene.xml', trim`
@@ -171,10 +171,29 @@ describe('SGParser', () => {
`);
- expect(parser.diagnostics).to.be.lengthOf(1);
- expect(parser.diagnostics[0]).to.deep.include({
- ...DiagnosticMessages.xmlTagMismatch('Group', 'LayoutGroup'),
- range: Range.create(4, 10, 4, 21)
- });
+ //parsing does not emit the mismatch diagnostic; that happens at validation time
+ expect(parser.diagnostics).to.be.lengthOf(0);
+ //but the parser should capture the (mismatched) closing tag so validation can inspect it
+ const group = parser.ast.component.children.children[0];
+ expect(group.tag.text).to.equal('Group');
+ expect(group.closingTag?.text).to.equal('LayoutGroup');
+ expect(group.closingTag?.range).to.eql(Range.create(4, 10, 4, 21));
+ });
+
+ it('leaves closingTag undefined for self-closing tags', () => {
+ const parser = new SGParser();
+ parser.parse(
+ 'pkg:/components/ParentScene.xml', trim`
+
+
+
+
+
+
+ `);
+ expect(parser.diagnostics).to.be.lengthOf(0);
+ const group = parser.ast.component.children.children[0];
+ expect(group.tag.text).to.equal('Group');
+ expect(group.closingTag).to.be.undefined;
});
});
diff --git a/src/parser/SGParser.ts b/src/parser/SGParser.ts
index 2d8ab04a9..7ccaf1ab5 100644
--- a/src/parser/SGParser.ts
+++ b/src/parser/SGParser.ts
@@ -188,14 +188,6 @@ function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SG
const name = mapToken(nameToken);
const closingName = closingNameToken ? mapToken(closingNameToken) : undefined;
- // Check for tag mismatch
- if (closingName && name.text !== closingName.text) {
- diagnostics.push({
- ...DiagnosticMessages.xmlTagMismatch(name.text, closingName.text),
- range: closingName.range
- });
- }
-
const attributes = mapAttributes(children.attribute);
const content = children.content?.[0];
switch (name.text) {
@@ -222,10 +214,10 @@ function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SG
const cdata = getCdata(content);
return new SGScript(name, attributes, cdata, range, closingName);
case 'children':
- const childrenContent = mapNodes(content, diagnostics);
+ const childrenContent = mapNodes(content);
return new SGChildren(name, childrenContent, range, closingName);
default:
- const nodeContent = mapNodes(content, diagnostics);
+ const nodeContent = mapNodes(content);
return new SGNode(name, attributes, nodeContent, range, closingName);
}
}
@@ -237,7 +229,7 @@ function reportUnexpectedChildren(name: SGToken, diagnostics: Diagnostic[]) {
});
}
-function mapNode({ children }: ElementCstNode, diagnostics?: Diagnostic[]): SGNode {
+function mapNode({ children }: ElementCstNode): SGNode {
const nameToken = children.Name[0];
const closingNameToken = children.END_NAME?.[0];
let range: Range;
@@ -252,17 +244,9 @@ function mapNode({ children }: ElementCstNode, diagnostics?: Diagnostic[]): SGNo
const name = mapToken(nameToken);
const closingName = closingNameToken ? mapToken(closingNameToken) : undefined;
- // Check for tag mismatch
- if (closingName && name.text !== closingName.text && diagnostics) {
- diagnostics.push({
- ...DiagnosticMessages.xmlTagMismatch(name.text, closingName.text),
- range: closingName.range
- });
- }
-
const attributes = mapAttributes(children.attribute);
const content = children.content?.[0];
- const nodeContent = mapNodes(content, diagnostics);
+ const nodeContent = mapNodes(content);
return new SGNode(name, attributes, nodeContent, range, closingName);
}
@@ -293,12 +277,12 @@ function mapElements(content: ContentCstNode, allow: string[], diagnostics: Diag
return tags;
}
-function mapNodes(content: ContentCstNode, diagnostics?: Diagnostic[]): SGNode[] {
+function mapNodes(content: ContentCstNode): SGNode[] {
if (!content) {
return [];
}
const { element } = content.children;
- return element?.map(element => mapNode(element, diagnostics)) ?? [];
+ return element?.map(element => mapNode(element));
}
function hasElements(content: ContentCstNode): boolean {
diff --git a/src/parser/SGTypes.spec.ts b/src/parser/SGTypes.spec.ts
new file mode 100644
index 000000000..98ad5fb5c
--- /dev/null
+++ b/src/parser/SGTypes.spec.ts
@@ -0,0 +1,98 @@
+import { expect } from '../chai-config.spec';
+import { trim } from '../testHelpers.spec';
+import { SGChildren, SGComponent, SGField, SGFunction, SGInterface, SGNode, SGScript } from './SGTypes';
+import { TranspileState } from './TranspileState';
+
+/**
+ * These tests build the SG AST programmatically (the way a plugin would) WITHOUT
+ * providing a `closingTag`. They prove that every affected tag type still behaves
+ * correctly when `closingTag` is absent, guaranteeing backwards compatibility with
+ * any code (parser or plugin) that constructs these nodes the old way.
+ */
+describe('SGTypes', () => {
+ function transpile(tag: SGNode): string {
+ return tag.transpile(new TranspileState('pkg:/components/Comp.xml', { rootDir: '' })).toString();
+ }
+
+ describe('backwards compatibility (no closingTag provided)', () => {
+ it('SGNode leaves closingTag undefined and derives the closing tag from the opening tag', () => {
+ const node = new SGNode({ text: 'Group' }, [], [
+ new SGNode({ text: 'Label' })
+ ]);
+ expect(node.closingTag).to.be.undefined;
+ //closing tag in the output is derived from the opening tag name, not from closingTag
+ expect(transpile(node)).to.equal(trim`
+
+
+
+ ` + '\n');
+ });
+
+ it('SGNode with no children transpiles as self-closing', () => {
+ const node = new SGNode({ text: 'Group' });
+ expect(node.closingTag).to.be.undefined;
+ expect(transpile(node)).to.equal('\n');
+ });
+
+ it('SGChildren defaults work without closingTag', () => {
+ const children = new SGChildren();
+ expect(children.closingTag).to.be.undefined;
+ expect(children.tag.text).to.equal('children');
+ expect(children.getChildren()).to.eql([]);
+ });
+
+ it('SGScript works without closingTag', () => {
+ const script = new SGScript();
+ expect(script.closingTag).to.be.undefined;
+ //defaults to text/brightscript when no attributes provided
+ expect(script.type).to.equal('text/brightscript');
+ expect(script.getChildren()).to.eql([]);
+ });
+
+ it('SGField works without closingTag', () => {
+ const field = new SGField();
+ expect(field.closingTag).to.be.undefined;
+ expect(field.tag.text).to.equal('field');
+ expect(field.getChildren()).to.eql([]);
+ });
+
+ it('SGFunction works without closingTag', () => {
+ const func = new SGFunction();
+ expect(func.closingTag).to.be.undefined;
+ expect(func.tag.text).to.equal('function');
+ expect(func.getChildren()).to.eql([]);
+ });
+
+ it('SGInterface works without closingTag and reports its children', () => {
+ const field = new SGField({ text: 'field' }, [{ key: { text: 'id' }, value: { text: 'foo' } }]);
+ const func = new SGFunction({ text: 'function' }, [{ key: { text: 'name' }, value: { text: 'bar' } }]);
+ const iface = new SGInterface({ text: 'interface' }, [field, func]);
+ expect(iface.closingTag).to.be.undefined;
+ expect(iface.getChildren()).to.eql([field, func]);
+ });
+
+ it('SGComponent works without closingTag and reports its children', () => {
+ const iface = new SGInterface();
+ const script = new SGScript();
+ const children = new SGChildren();
+ const component = new SGComponent({ text: 'component' }, [], [iface, script, children]);
+ expect(component.closingTag).to.be.undefined;
+ expect(component.getChildren()).to.eql([iface, script, children]);
+ });
+
+ it('a deeply nested tree built without closingTag transpiles with matching closing tags', () => {
+ const node = new SGNode({ text: 'Group' }, [], [
+ new SGNode({ text: 'Rectangle' }, [], [
+ new SGNode({ text: 'Label' })
+ ])
+ ]);
+ expect(transpile(node)).to.equal(trim`
+
+
+
+
+
+ ` + '\n');
+ });
+ });
+});
diff --git a/src/parser/SGTypes.ts b/src/parser/SGTypes.ts
index 47220cc24..1fece6811 100644
--- a/src/parser/SGTypes.ts
+++ b/src/parser/SGTypes.ts
@@ -60,6 +60,15 @@ export class SGTag {
}
}
+ /**
+ * The nested tags directly contained by this tag. Base tags have no children;
+ * subclasses that contain other tags override this. Used for AST traversal
+ * (e.g. validation) so it works regardless of how the AST was constructed.
+ */
+ public getChildren(): SGTag[] {
+ return [];
+ }
+
transpile(state: TranspileState): SourceNode {
return new SourceNode(null, null, state.srcPath, [
state.indentText,
@@ -113,6 +122,10 @@ export class SGNode extends SGTag {
super(tag, attributes, range, closingTag);
}
+ public getChildren(): SGTag[] {
+ return this.children;
+ }
+
protected transpileBody(state: TranspileState): (string | SourceNode)[] {
if (this.children.length > 0) {
const body: (string | SourceNode)[] = ['>\n'];
@@ -343,6 +356,10 @@ export class SGInterface extends SGTag {
}
}
+ public getChildren(): SGTag[] {
+ return [...this.fields, ...this.functions];
+ }
+
protected transpileBody(state: TranspileState): (string | SourceNode)[] {
const body: (string | SourceNode)[] = ['>\n'];
state.blockDepth++;
@@ -404,6 +421,19 @@ export class SGComponent extends SGTag {
this.setAttribute('extends', value);
}
+ public getChildren(): SGTag[] {
+ const result: SGTag[] = [];
+ if (this.api) {
+ result.push(this.api);
+ }
+ result.push(...this.scripts);
+ if (this.children) {
+ result.push(this.children);
+ }
+ result.push(...this.customizations);
+ return result;
+ }
+
protected transpileBody(state: TranspileState): (string | SourceNode)[] {
const body: (string | SourceNode)[] = ['>\n'];
state.blockDepth++;