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
2 changes: 1 addition & 1 deletion src/DiagnosticMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,7 @@ export let DiagnosticMessages = {
code: 'function-not-found'
}),
xmlInvalidFieldType: (name: string) => ({
message: `Invalid field type ${name}`,
message: `Invalid field type '${name}'`,
legacyCode: 1068,
severity: DiagnosticSeverity.Error,
code: 'invalid-field-type'
Expand Down
106 changes: 106 additions & 0 deletions src/XmlScope.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -315,5 +315,111 @@ describe('XmlScope', () => {
expectZeroDiagnostics(program);
});

describe('custom types', () => {
it('allows built-in node types as field types', () => {
program.setFile('components/Widget.xml', trim`
<?xml version="1.0" encoding="utf-8" ?>
<component name="Widget" extends="Group">
<interface>
<field id="labelNode" type="roSGNodeLabel" />
</interface>
</component>
`);
program.validate();
expectZeroDiagnostics(program);
const widgetTypeResult = program.globalScope.symbolTable.getSymbolType('roSGNodeWidget', { flags: SymbolTypeFlag.typetime });
expectTypeToBe(widgetTypeResult, ComponentType);
const widgetType = widgetTypeResult as ComponentType;
const labelNodeType = widgetType.getMemberType('labelNode', { flags: SymbolTypeFlag.runtime });
expectTypeToBe(labelNodeType, ComponentType);
expectTypeToBe(labelNodeType.getMemberType('text', { flags: SymbolTypeFlag.runtime }), StringType);
});

it('allows unions of primitive types as field types', () => {
program.setFile('components/Widget.xml', trim`
<?xml version="1.0" encoding="utf-8" ?>
<component name="Widget" extends="Group">
<interface>
<field id="publicId" type="integer or string" />
</interface>
</component>
`);
program.validate();
expectZeroDiagnostics(program);
const widgetTypeResult = program.globalScope.symbolTable.getSymbolType('roSGNodeWidget', { flags: SymbolTypeFlag.typetime });
expectTypeToBe(widgetTypeResult, ComponentType);
const widgetType = widgetTypeResult as ComponentType;
const publicIdType = widgetType.getMemberType('publicId', { flags: SymbolTypeFlag.runtime }) as UnionType;
expectTypeToBe(publicIdType, UnionType);
expect(publicIdType.types).to.include(IntegerType.instance);
expect(publicIdType.types).to.include(StringType.instance);
});

it('disallows unknown types', () => {
program.setFile('components/Widget.xml', trim`
<?xml version="1.0" encoding="utf-8" ?>
<component name="Widget" extends="Group">
<interface>
<field id="labelNode" type="UnknownType" />
</interface>
</component>
`);
program.validate();
expectDiagnostics(program, [{
...DiagnosticMessages.xmlInvalidFieldType('UnknownType'),
location: { range: Range.create(3, 36, 3, 47) }
}]);
});

it('allows types defined in bs files in the scope', () => {
program.setFile('components/Widget.xml', trim`
<?xml version="1.0" encoding="utf-8" ?>
<component name="Widget" extends="Group">
<script uri="Widget.bs"/>
<interface>
<field id="labelNode" type="DefinedType" />
</interface>
</component>
`);
program.setFile('components/Widget.bs', trim`
interface DefinedType
name as string
end interface
`);
program.validate();
expectZeroDiagnostics(program);
});

it('allows inline interface types', () => {
program.setFile('components/Widget.xml', trim`
<?xml version="1.0" encoding="utf-8" ?>
<component name="Widget" extends="Group">
<interface>
<field id="data" type="{id as string, num as integer}" />
</interface>
</component>
`);
program.validate();
expectZeroDiagnostics(program);
});

it('has an error on malformed types', () => {
program.setFile('components/Widget.xml', trim`
<?xml version="1.0" encoding="utf-8" ?>
<component name="Widget" extends="Group">
<interface>
<field id="data" type="just a bunch of random text" />
</interface>
</component>
`);
program.validate();
expectDiagnostics(program, [
// <field id="data" type="just *a* bunch of random text" />
{ ...DiagnosticMessages.unexpectedToken('a'), location: { range: Range.create(3, 36, 3, 37) } },
// <field id="data" type="*just a bunch of random text*" />
{ ...DiagnosticMessages.xmlInvalidFieldType('just a bunch of random text'), location: { range: Range.create(3, 31, 3, 58) } }
]);
});
});
});
});
35 changes: 33 additions & 2 deletions src/XmlScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import type { Program } from './Program';
import util from './util';
import { SymbolTypeFlag } from './SymbolTypeFlag';
import type { BscFile } from './files/BscFile';
import { DynamicType } from './types/DynamicType';
import type { BaseFunctionType } from './types/BaseFunctionType';
import { ComponentType } from './types/ComponentType';
import type { ExtraSymbolData } from './interfaces';
import { ParseMode, Parser } from './parser/Parser';
import { isTypeExpression } from './astUtils/reflection';
import { DynamicType } from './types';

export class XmlScope extends Scope {
constructor(
Expand All @@ -17,6 +19,8 @@ export class XmlScope extends Scope {
super(xmlFile.destPath, program);
}

private typeParser = new Parser();

public get dependencyGraphKey() {
return this.xmlFile.dependencyGraphKey;
}
Expand Down Expand Up @@ -65,7 +69,34 @@ export class XmlScope extends Scope {
//add fields
for (const field of iface.fields ?? []) {
if (field.id) {
const actualFieldType = field.type ? util.getNodeFieldType(field.type, this.symbolTable) : DynamicType.instance;

let actualFieldType = field.type ? util.getNodeFieldType(field.type, this.symbolTable, false) : DynamicType.instance;

if (!actualFieldType && field.type) {
//try to parse the type as an expression, this allows for more complex types like arrays or interfaces
try {

const typeAttrValue = field.attributes.find(attr => attr.key === 'type')?.tokens?.value;
const parsed = this.typeParser.parse(field.type ?? '', {
mode: ParseMode.BrighterScript,
srcPath: this.xmlFile.srcPath,
typeOnly: true,
rangeOffset: typeAttrValue?.location?.range.start
});
const ast = parsed?.ast;
const typeExpression = ast?.statements[0];
if (isTypeExpression(typeExpression)) {
actualFieldType = typeExpression.getType({ flags: SymbolTypeFlag.typetime });
}
if (parsed?.diagnostics.length) {
this.program?.diagnostics.register(parsed.diagnostics);
}

} catch (e) {
}

}
field.bscType = actualFieldType;
//TODO: add documentation - need to get previous comment from XML
result.addMember(field.id, {}, actualFieldType, SymbolTypeFlag.runtime);
}
Expand Down
13 changes: 9 additions & 4 deletions src/bscPlugin/validation/ScopeValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1505,10 +1505,15 @@ export class ScopeValidator {
}, ScopeValidatorDiagnosticTag.XMLInterface);
}
} else if (!SGFieldTypes.includes(type.toLowerCase())) {
this.addDiagnostic({
...DiagnosticMessages.xmlInvalidFieldType(type),
location: field.getAttribute('type')?.tokens.value.location
}, ScopeValidatorDiagnosticTag.XMLInterface);
// type might be a custom type
const memberType = field.bscType;
if (memberType && !memberType.isResolvable()) {
// there is a type defined, but could not resolve this field type
this.addDiagnostic({
...DiagnosticMessages.xmlInvalidFieldType(type),
location: field.getAttribute('type')?.tokens.value.location
}, ScopeValidatorDiagnosticTag.XMLInterface);
}
}
if (onChange) {
if (!callableContainerMap.has(onChange.toLowerCase())) {
Expand Down
4 changes: 2 additions & 2 deletions src/files/XmlFile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,11 +227,11 @@ export class XmlFile implements BscFile {
});
}
// TODO: when we can specify proper types in fields, add those types too:
//if (node.type && isCustomXmlType(node.type)) {
//if (node.bscType && isCustomXmlType(node.bscType)) {
// requiredSymbols.push({
// flags: SymbolTypeFlag.typetime,
// file: this,
// name: node.type.toLowerCase()
// name: node.bscType.toLowerCase()
// });
//}
}
Expand Down
15 changes: 10 additions & 5 deletions src/lexer/Lexer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import { TokenKind, ReservedWords, Keywords, PreceedingRegexTypes, AllowedTriviaTokens } from './TokenKind';
import type { Token } from './Token';
import { isAlpha, isDecimalDigit, isAlphaNumeric, isHexDigit } from './Characters';
import type { Location } from 'vscode-languageserver';
import type { Location, Position } from 'vscode-languageserver';
import { DiagnosticMessages } from '../DiagnosticMessages';
import util from '../util';
import type { BsDiagnostic } from '../interfaces';
Expand Down Expand Up @@ -104,10 +104,10 @@ export class Lexer {
this.options = this.sanitizeOptions(options);
this.start = 0;
this.current = 0;
this.lineBegin = 0;
this.lineEnd = 0;
this.columnBegin = 0;
this.columnEnd = 0;
this.lineBegin = options?.rangeOffset?.line ?? 0;
this.lineEnd = options?.rangeOffset?.line ?? 0;
this.columnBegin = options?.rangeOffset?.character ?? 0;
this.columnEnd = options?.rangeOffset?.character ?? 0;
this.tokens = [];
this.diagnostics = [];
this.uri = util.pathToUri(options?.srcPath);
Expand Down Expand Up @@ -1133,4 +1133,9 @@ export interface ScanOptions {
* Path to the file where this source code originated
*/
srcPath?: string;
/**
* When parsing sections of a document, offset the range to the beginning of the text
*/
rangeOffset?: Position;

}
29 changes: 25 additions & 4 deletions src/parser/Parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ import {
InlineInterfaceMemberExpression,
TypedFunctionTypeExpression
} from './Expression';
import type { Range } from 'vscode-languageserver';
import type { Position, Range } from 'vscode-languageserver';
import type { Logger } from '../logging';
import { createLogger } from '../logging';
import { isAnnotationExpression, isCallExpression, isCallfuncExpression, isDottedGetExpression, isIfStatement, isIndexedGetExpression, isVariableExpression, isConditionalCompileStatement, isLiteralBoolean, isTypecastExpression } from '../astUtils/reflection';
Expand Down Expand Up @@ -196,7 +196,8 @@ export class Parser {
if (typeof toParse === 'string') {
tokens = Lexer.scan(toParse, {
trackLocations: options.trackLocations,
srcPath: options?.srcPath
srcPath: options?.srcPath,
rangeOffset: options?.rangeOffset
}).tokens;
} else {
tokens = toParse;
Expand All @@ -212,7 +213,17 @@ export class Parser {
this.namespaceAndFunctionDepth = 0;
this.pendingAnnotations = [];

this.ast = this.body();
if (options.typeOnly) {
this.ast.statements.push(this.typeExpression());
if (!this.isAtEnd()) {
this.diagnostics.push({
...DiagnosticMessages.unexpectedToken(this.peek().text),
location: this.peek().location
});
}
} else {
this.ast = this.body();
}
this.ast.bsConsts = options.bsConsts;
//now that we've built the AST, link every node to its parent
this.ast.link();
Expand Down Expand Up @@ -1460,7 +1471,7 @@ export class Parser {
private identifyingExpression(allowedTokenKinds?: TokenKind[]): DottedGetExpression | VariableExpression {
allowedTokenKinds = allowedTokenKinds ?? this.allowedLocalIdentifiers;
let firstIdentifier = this.consume(
DiagnosticMessages.expectedIdentifier(this.previous().text),
DiagnosticMessages.expectedIdentifier(this.previous()?.text),
TokenKind.Identifier,
...allowedTokenKinds
) as Identifier;
Expand Down Expand Up @@ -3757,6 +3768,16 @@ export interface ParseOptions {
*
*/
bsConsts?: Map<string, boolean>;
/**
* When true, the parser will only parse types, and will not attempt to parse expressions or statements.
* This is used when parsing the type of field in XML.
* In this case, there will be one TypeExpression in the Ast.statements array
*/
typeOnly?: boolean;
/**
* When parsing sections of a document, offset the range to the beginning of the text
*/
rangeOffset?: Position;
}


Expand Down
3 changes: 3 additions & 0 deletions src/parser/SGTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { createSGAttribute, createSGInterface, createSGInterfaceField, createSGI
import type { FileReference, TranspileResult } from '../interfaces';
import util from '../util';
import type { TranspileState } from './TranspileState';
import type { BscType } from '../types';

export interface SGToken {
text: string;
Expand Down Expand Up @@ -451,6 +452,8 @@ export class SGInterfaceField extends SGElement {
set alwaysNotify(value: string) {
this.setAttributeValue('alwaysNotify', value);
}

bscType: BscType;
}

export enum SGFieldType {
Expand Down
8 changes: 6 additions & 2 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1248,7 +1248,7 @@ export class Util {
* @param typeDescriptor the type descriptor from the docs
* @returns {BscType} the known type, or dynamic
*/
public getNodeFieldType(typeDescriptor: string, lookupTable?: SymbolTable): BscType {
public getNodeFieldType(typeDescriptor: string, lookupTable?: SymbolTable, dynamicIfNotFound = true): BscType {
let typeDescriptorLower = typeDescriptor.toLowerCase().trim().replace(/\*/g, '');

if (typeDescriptorLower.startsWith('as ')) {
Expand Down Expand Up @@ -1364,7 +1364,7 @@ export class Util {
return this.getNodeFieldType('roSGNodeContentNode', lookupTable);
} else if (typeDescriptorLower.endsWith(' node')) {
return this.getNodeFieldType('roSgNode' + typeDescriptorLower.substring(0, typeDescriptorLower.length - 5), lookupTable);
} else if (lookupTable) {
} else if (lookupTable && !typeDescriptorLower.includes(' ')) {
//try doing a lookup
return lookupTable.getSymbolType(typeDescriptorLower, {
flags: SymbolTypeFlag.typetime,
Expand All @@ -1373,6 +1373,10 @@ export class Util {
});
}

if (!dynamicIfNotFound) {
return undefined;
}

return DynamicType.instance;
}

Expand Down