-
Notifications
You must be signed in to change notification settings - Fork 64
Validate SceneGraph node types and fields in xml components #1742
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 5 commits
2cf4b03
b5f661f
a27b5e5
95d7393
2e72bc9
7abfbb7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -833,6 +833,26 @@ export let DiagnosticMessages = { | |
| message: `rsg_version=${rsgVersion} was removed in firmware ${removedAt}; use rsg_version=${replacement}`, | ||
| code: 1154, | ||
| severity: DiagnosticSeverity.Error | ||
| }), | ||
| xmlUnknownComponentType: (nodeName: string) => ({ | ||
| message: `Unknown component type '${nodeName}'`, | ||
| code: 1155, | ||
| severity: DiagnosticSeverity.Error | ||
| }), | ||
| xmlUnknownField: (fieldName: string, componentName: string) => ({ | ||
| message: `Field '${fieldName}' does not exist on component '${componentName}'`, | ||
| code: 1156, | ||
| severity: DiagnosticSeverity.Error | ||
| }), | ||
| xmlFieldNameCaseMismatch: (actualName: string, expectedName: string) => ({ | ||
| message: `Field '${actualName}' should be '${expectedName}' to match the declared casing`, | ||
| code: 1157, | ||
| severity: DiagnosticSeverity.Warning | ||
| }), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This diagnostic belongs in eslint. it's a preference thing, not something that would cause runtime errors. |
||
| xmlInvalidFieldValue: (fieldName: string, expectedType: string) => ({ | ||
| message: `Value for field '${fieldName}' is not a valid '${expectedType}'`, | ||
| code: 1158, | ||
| severity: DiagnosticSeverity.Warning | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should be an error |
||
| }) | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| /** | ||
| * Best-effort validation of a SceneGraph field value written as an xml attribute string. | ||
| * | ||
| * Intentionally conservative: only unambiguous scalar types are checked. Anything ambiguous (strings, | ||
| * nodes, uris, arrays, vectors, enums/option-strings, unknown types, etc.) passes, so we never emit a | ||
| * false positive for the many field types whose valid literal form is hard to pin down from xml alone. | ||
| * | ||
| * @param value the (unquoted) attribute value | ||
| * @param type the field's declared type (e.g. `integer`, `float`, `boolean`, `color`, `option string`) | ||
| * @returns true when the value is valid for the type, or when the type isn't one we confidently validate | ||
| */ | ||
| export function isValidSceneGraphFieldValue(value: string, type: string): boolean { | ||
| const validator = scalarValidators[normalizeFieldType(type)]; | ||
| //no validator for this type -> assume valid | ||
| return validator ? validator(value ?? '') : true; | ||
| } | ||
|
|
||
| function normalizeFieldType(type: string): string { | ||
| let normalized = (type ?? '').trim().toLowerCase(); | ||
| //"option string", "value string", etc. are all just strings | ||
| if (normalized.endsWith(' string')) { | ||
| normalized = 'string'; | ||
| } | ||
| return normalized; | ||
| } | ||
|
|
||
| const integerPattern = /^[+-]?\d+$/; | ||
| const floatPattern = /^[+-]?(\d+\.?\d*|\.\d+)$/; | ||
| const booleanPattern = /^(true|false)$/i; | ||
| const colorPattern = /^(#[0-9a-f]{3,4}|#[0-9a-f]{6}|#[0-9a-f]{8}|0x[0-9a-f]{6}|0x[0-9a-f]{8})$/i; | ||
|
|
||
| const scalarValidators: Record<string, (value: string) => boolean> = { | ||
| integer: value => integerPattern.test(value), | ||
| int: value => integerPattern.test(value), | ||
| longinteger: value => integerPattern.test(value), | ||
| float: value => floatPattern.test(value), | ||
| double: value => floatPattern.test(value), | ||
| time: value => floatPattern.test(value), | ||
| boolean: value => booleanPattern.test(value), | ||
| bool: value => booleanPattern.test(value), | ||
| color: value => colorPattern.test(value) | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,112 @@ | ||
| import { expect } from '../../chai-config.spec'; | ||
| import { Program } from '../../Program'; | ||
| import type { XmlFile } from '../../files/XmlFile'; | ||
| import type { BsDiagnostic } from '../../interfaces'; | ||
| import { trim, rootDir } from '../../testHelpers.spec'; | ||
|
|
||
| describe('XmlFileValidator', () => { | ||
| let program: Program; | ||
| beforeEach(() => { | ||
| program = new Program({ rootDir: rootDir }); | ||
| }); | ||
| afterEach(() => { | ||
| program.dispose(); | ||
| }); | ||
|
|
||
| //codes for the scenegraph node/field diagnostics added by this validator | ||
| const scenegraphCodes = [1155, 1156, 1157, 1158]; | ||
| function scenegraphDiagnostics(file: XmlFile): BsDiagnostic[] { | ||
| return file.diagnostics.filter(diagnostic => scenegraphCodes.includes(diagnostic.code as number)); | ||
| } | ||
|
|
||
| it('flags an unknown component type in <children>', () => { | ||
| const file = program.setFile<XmlFile>('components/main.xml', trim` | ||
| <component name="Main" extends="Group"> | ||
| <children> | ||
| <Lable /> | ||
| </children> | ||
| </component> | ||
| `); | ||
| program.validate(); | ||
| const diagnostics = scenegraphDiagnostics(file); | ||
| expect(diagnostics.map(x => x.code)).to.include(1155); | ||
| expect(diagnostics[0].message).to.include('Lable'); | ||
| }); | ||
|
|
||
| it('does not flag known built-in nodes or project components', () => { | ||
| program.setFile('components/widget.xml', trim` | ||
| <component name="Widget" extends="Group"> | ||
| </component> | ||
| `); | ||
| const file = program.setFile<XmlFile>('components/main.xml', trim` | ||
| <component name="Main" extends="Group"> | ||
| <children> | ||
| <Label text="hi" /> | ||
| <Widget /> | ||
| </children> | ||
| </component> | ||
| `); | ||
| program.validate(); | ||
| expect(scenegraphDiagnostics(file)).to.be.empty; | ||
| }); | ||
|
|
||
| it('flags an unknown field on a known node', () => { | ||
| const file = program.setFile<XmlFile>('components/main.xml', trim` | ||
| <component name="Main" extends="Group"> | ||
| <children> | ||
| <Label bogusField="x" /> | ||
| </children> | ||
| </component> | ||
| `); | ||
| program.validate(); | ||
| expect(scenegraphDiagnostics(file).map(x => x.code)).to.include(1156); | ||
| }); | ||
|
|
||
| it('flags a field name case mismatch', () => { | ||
| const file = program.setFile<XmlFile>('components/main.xml', trim` | ||
| <component name="Main" extends="Group"> | ||
| <children> | ||
| <Label Text="hi" /> | ||
| </children> | ||
| </component> | ||
| `); | ||
| program.validate(); | ||
| expect(scenegraphDiagnostics(file).map(x => x.code)).to.include(1157); | ||
| }); | ||
|
|
||
| it('flags a clearly-invalid scalar field value but allows valid and ambiguous ones', () => { | ||
| const badFile = program.setFile<XmlFile>('components/bad.xml', trim` | ||
| <component name="Bad" extends="Group"> | ||
| <children> | ||
| <Label opacity="halfway" /> | ||
| </children> | ||
| </component> | ||
| `); | ||
| const goodFile = program.setFile<XmlFile>('components/good.xml', trim` | ||
| <component name="Good" extends="Group"> | ||
| <children> | ||
| <Label opacity="0.5" text="anything goes" /> | ||
| </children> | ||
| </component> | ||
| `); | ||
| program.validate(); | ||
| expect(scenegraphDiagnostics(badFile).map(x => x.code)).to.include(1158); | ||
| expect(scenegraphDiagnostics(goodFile)).to.be.empty; | ||
| }); | ||
|
|
||
| it('validates fields on ContentNode against its known schema', () => { | ||
| const file = program.setFile<XmlFile>('components/main.xml', trim` | ||
| <component name="Main" extends="Group"> | ||
| <children> | ||
| <ContentNode Title="Home" bogusField="x" /> | ||
| </children> | ||
| </component> | ||
| `); | ||
| program.validate(); | ||
| const codes = scenegraphDiagnostics(file).map(x => x.code); | ||
| //`Title` is a real ContentNode field, correctly cased -> no diagnostic | ||
| expect(codes).not.to.include(1157); | ||
| //`bogusField` is not a ContentNode field -> unknown field | ||
| expect(codes).to.include(1156); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,8 +1,9 @@ | ||
| import { DiagnosticMessages } from '../../DiagnosticMessages'; | ||
| import type { XmlFile } from '../../files/XmlFile'; | ||
| import type { OnFileValidateEvent } from '../../interfaces'; | ||
| import type { SGAst } from '../../parser/SGTypes'; | ||
| import type { SGAst, SGNode } from '../../parser/SGTypes'; | ||
| import util from '../../util'; | ||
| import { isValidSceneGraphFieldValue } from './FieldTypeValidator'; | ||
|
|
||
| export class XmlFileValidator { | ||
| constructor( | ||
|
|
@@ -60,6 +61,67 @@ export class XmlFileValidator { | |
| range: explicitCodebehindScriptTag.filePathRange | ||
| }); | ||
| } | ||
|
|
||
| //validate the node instances declared in the <children> block | ||
| for (const node of component.children?.children ?? []) { | ||
| this.validateNode(node); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Validate a single node instance and recurse into its children. Flags unknown node types, and (for | ||
| * known types) unknown/mis-cased field names and clearly-invalid field values. | ||
| */ | ||
| private validateNode(node: SGNode) { | ||
| const nodeName = node.tag.text; | ||
| if (this.event.program.hasSceneGraphNode(nodeName)) { | ||
| this.validateNodeAttributes(node, nodeName); | ||
| } else { | ||
| this.event.file.diagnostics.push({ | ||
| ...DiagnosticMessages.xmlUnknownComponentType(nodeName), | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This works great for known xml files. However, for devs that use component libraries, all of their components are going to show up as errors. I think for now, we should avoid adding this diagnostic for |
||
| range: node.tag.range, | ||
| file: this.event.file | ||
| }); | ||
| } | ||
| //recurse regardless, so nested typos are still reported | ||
| for (const child of node.children ?? []) { | ||
| this.validateNode(child); | ||
| } | ||
| } | ||
|
|
||
| private validateNodeAttributes(node: SGNode, nodeName: string) { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This works great for out-of-the-box attributes. But many dev teams have custom plugins that do things like handlebars replacements. Those would all be marked as warnings (or errors) with no way to granularly say "brighterscript can validate these, I will validate those others". maybe a plugin hook for |
||
| const fields = this.event.program.getSceneGraphNodeFields(nodeName); | ||
| //if we can't resolve any fields for a known node, don't guess (avoids mass false positives) | ||
| if (fields.length === 0) { | ||
| return; | ||
| } | ||
| const fieldsByLowerName = new Map(fields.map(field => [field.name.toLowerCase(), field])); | ||
| for (const attribute of node.attributes ?? []) { | ||
| const attributeName = attribute.key.text; | ||
| const field = fieldsByLowerName.get(attributeName.toLowerCase()); | ||
| if (!field) { | ||
| this.event.file.diagnostics.push({ | ||
| ...DiagnosticMessages.xmlUnknownField(attributeName, nodeName), | ||
| range: attribute.key.range, | ||
| file: this.event.file | ||
| }); | ||
| continue; | ||
| } | ||
| if (field.name !== attributeName) { | ||
| this.event.file.diagnostics.push({ | ||
| ...DiagnosticMessages.xmlFieldNameCaseMismatch(attributeName, field.name), | ||
| range: attribute.key.range, | ||
| file: this.event.file | ||
| }); | ||
| } | ||
| if (field.type && !isValidSceneGraphFieldValue(attribute.value?.text, field.type)) { | ||
| this.event.file.diagnostics.push({ | ||
| ...DiagnosticMessages.xmlInvalidFieldValue(field.name, field.type), | ||
| range: attribute.value?.range ?? attribute.key.range, | ||
| file: this.event.file | ||
| }); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need additional diagonstics for mismatched starting and ending xml attribute names