Skip to content
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

Component statement support for <children> and field attributes #971

Open
wants to merge 28 commits into
base: in-code-component
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5f55915
Adds the template children and field attributes to the component
iObject Nov 30, 2023
086e267
Validate annotations being send to getAnnotationValue, omit empty fie…
iObject Nov 30, 2023
4366da9
Update src/bscPlugin/fileProviders/ComponentStatementProvider.ts
iObject Nov 30, 2023
af68592
Update src/bscPlugin/fileProviders/ComponentStatementProvider.ts
iObject Nov 30, 2023
03fc579
Update src/bscPlugin/fileProviders/ComponentStatementProvider.ts
iObject Nov 30, 2023
a1630c4
Add AnnotaionExpression type
iObject Nov 30, 2023
b397e89
initialFocus
iObject Nov 30, 2023
5325d9e
Update component-statement.md to indicate requirement of method access
iObject Nov 30, 2023
fd613b6
Remove space
iObject Nov 30, 2023
fe05c8a
Remove comments
iObject Nov 30, 2023
a14a35f
Add tests
iObject Nov 30, 2023
92b99c1
Merge branch 'in-code-component' into add-component-children-and-fiel…
iObject Nov 30, 2023
6f13778
remove obj lookup in annotation lookup
iObject Nov 30, 2023
f58c40b
remove describe.only
iObject Nov 30, 2023
7c98830
remove semicolon
iObject Nov 30, 2023
cfc105a
Update src/parser/tests/statement/ComponentStatement.spec.ts
iObject Dec 1, 2023
b411e00
readd skip
iObject Dec 1, 2023
e4579cd
Merge branch 'in-code-component' into add-component-children-and-fiel…
iObject Dec 1, 2023
4508c43
Add pkgPath
iObject Dec 1, 2023
b79f963
Fix tests
iObject Dec 1, 2023
6dc246d
Fix tests part 2
iObject Dec 1, 2023
8e04a6e
update spec
iObject Dec 4, 2023
bdd0320
Merge branch 'in-code-component' into add-component-children-and-fiel…
iObject Dec 4, 2023
2e4f737
Merge branch 'add-component-children-and-field-attributes' of https:/…
iObject Dec 4, 2023
2011c02
Confirm bs files are also generated
iObject Dec 4, 2023
6972a4e
Confirm bs files are also generated
iObject Dec 4, 2023
dd48bb2
Update xml template test
iObject Dec 4, 2023
b87c072
Merge branch 'in-code-component' into add-component-children-and-fiel…
iObject Jan 9, 2024
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
6 changes: 3 additions & 3 deletions docs/component-statement.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ You can define a component in code, similar to how you would define a class.
## Basic usage
```vb
component MoviePoster extends "Poster"
sub init()
private sub init()
print "MoviePoster init()"
end sub
end component
Expand Down Expand Up @@ -102,7 +102,7 @@ end component
Private properties are written to `m`. Private functions are transpiled to scope-level functions (i.e. not written to m) and we will remove the `m.` when calling those functions.
```vb
component MoviePoster extends "Poster"
sub init()
private sub init()
m.toggleSubtitles()
end sub
private areSubtitlesEnabled as boolean = true
Expand Down Expand Up @@ -347,7 +347,7 @@ XML component templates can also be loaded from another file by using the `@Temp
```vb
@TemplateUrl("./MoviePoster.xml")
component MoviePoster extends "Poster"
sub init()
private sub init()
print "MoviePoster"
end sub
end component
Expand Down
76 changes: 68 additions & 8 deletions src/bscPlugin/fileProviders/ComponentStatementProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,27 @@ import { Cache } from '../../Cache';
import * as path from 'path';
import { util } from '../../util';
import type { ProvideFileEvent } from '../../interfaces';
import { isDottedGetExpression, isFieldStatement, isMethodStatement, isVariableExpression } from '../../astUtils/reflection';
import { isDottedGetExpression, isFieldStatement, isMethodStatement, isVariableExpression, isLiteralExpression, isTemplateStringExpression, isAnnotationExpression } from '../../astUtils/reflection';
import { createFunctionStatement, createFunctionExpression, createDottedSetStatement, createVariableExpression } from '../../astUtils/creators';
import type { Statement } from '../../parser/AstNode';
import { TokenKind } from '../../lexer/TokenKind';
import { VariableExpression } from '../../parser/Expression';
import type { AnnotationExpression } from '../../parser/Expression';

export class ComponentStatementProvider {
constructor(
private event: ProvideFileEvent
) {
}


/**
* Create virtual files for every component statement found in this physical file
*/
public process(file: BrsFile) {
const cache = new Cache<string, string>();
file.ast.walk(createVisitor({
ComponentStatement: (node) => {
//force the desetPath for this component to be within the `pkg:/components` folder
//force the destPath for this component to be within the `pkg:/components` folder
const destDir = cache.getOrAdd(file.srcPath, () => {
return path.dirname(file.destPath).replace(/^(.+?)(?=[\/\\]|$)/, (match: string, firstDirName: string) => {
return 'components';
Expand Down Expand Up @@ -56,31 +56,91 @@ export class ComponentStatementProvider {

//declare interface field
} else if (isFieldStatement(member) && member.accessModifier?.text.toLowerCase() === 'public') {
return `<field id="${member.name.text}" type="${member.typeExpression.getName()}" />`;
return this.generateTagWithAttributes('field', {
id: member.name.text,
type: member.typeExpression.getName(),
alias: this.getAnnotationValue(member.annotations?.filter(x => x.name.toLowerCase() === 'alias')),
onChange: this.getAnnotationValue(member.annotations?.filter(x => x.name.toLowerCase() === 'onchange')),
alwaysNotify: this.getAnnotationValue(member.annotations?.filter(x => x.name.toLowerCase() === 'alwaysnotify')) === 'true' ? 'true' : ''
}, true);
} else {
return '';
}
}).filter(x => !!x);

let componentChildren = '';
const template = statement.annotations?.find(x => x.name.toLowerCase() === 'template');
if (isAnnotationExpression(template)) {
// TODO: Better strip of component and children elements.
componentChildren = `<children>${this.getAnnotationValue([template]).replaceAll('<component>', '').replaceAll('</component>', '').replaceAll('<children>', '').replaceAll('</children>', '')}</children>`;
}

let componentAttributes = {
name: name,
extends: statement.getParentName(ParseMode.BrightScript) ?? 'Group',
initialFocus: ''
};
const initialFocus = statement.annotations?.find(x => x.name.toLowerCase() === 'initialfocus');
if (isAnnotationExpression(initialFocus)) {
componentAttributes.initialFocus = this.getAnnotationValue([initialFocus]);
}
xmlFile.parse(undent`
<component name="${name}" extends="${statement.getParentName(ParseMode.BrightScript) ?? 'Group'}">
${this.generateTagWithAttributes('component', componentAttributes)};
<script uri="${util.sanitizePkgPath(file.destPath)}" />
<script uri="${util.sanitizePkgPath(codebehindFile.destPath)}" />
${interfaceMembers.length > 0 ? '<interface>' : ''}
${interfaceMembers.join('\n ')}
${interfaceMembers.length > 0 ? '</interface>' : ''}
${componentChildren}
</component>
`);


this.event.files.push(xmlFile);
}

private generateTagWithAttributes(identifier = '' as string, attributes = {} as Record<string, any>, closeEnd = false as boolean) {
let tag = `<${identifier}`;
Object.keys(attributes).forEach(attribute => {
let value = attributes[attribute];
// Only add a attribute if the value is not empty.
if (value !== '') {
tag += ` ${attribute}="${attributes[attribute]}"`;
}
});
tag += closeEnd ? ' />': ' >';
return tag;
}

private getAnnotationValue(annotations: AnnotationExpression[]) {
let response = [];
if (annotations !== undefined) {
for (const annotation of annotations) {
let args = annotation?.call?.args[0];
if (isVariableExpression(args) || isDottedGetExpression(args)) {
response.push(args.name.text);
} else if (isLiteralExpression(args)) {
let values = args?.token?.text.replaceAll('\"', '').replaceAll(' ', '').split(',');
response = response.concat(values);
} else if (isTemplateStringExpression(args)) {
let textOutput = '';
args.quasis[0]?.expressions?.forEach((a: { token: { text: string } }) => {
textOutput += a.token.text;
});
response.push(textOutput);
}
}

response = response.filter((item, index) => response.indexOf(item) === index);
}
return response.join(', ');
}

private registerCodebehind(name: string, statement: ComponentStatement, destDir: string) {
//create the codebehind file
const file = this.event.fileFactory.BrsFile({
srcPath: `virtual:/${destDir}/${name}.codebehind.bs`,
destPath: `${destDir}/${name}.codebehind.brs`
destPath: `${destDir}/${name}.codebehind.bs`,
pkgPath: `${destDir}/${name}.codebehind.brs`
});
const initStatements: Statement[] = [];
let initFunc: FunctionStatement;
Expand Down Expand Up @@ -121,7 +181,7 @@ export class ComponentStatementProvider {
initFunc.func.body.statements.unshift(...initStatements);
}

//TODO these are hacks that we need until scope has been refactored to leverate the AST directly
//TODO these are hacks that we need until scope has been refactored to leverage the AST directly
file.parser.invalidateReferences();
// eslint-disable-next-line @typescript-eslint/dot-notation
file['findCallables']();
Expand Down
150 changes: 144 additions & 6 deletions src/parser/tests/statement/ComponentStatement.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/await-thenable */
import util, { standardizePath as s } from '../../../util';
import { Program } from '../../../Program';
import { expectDiagnostics, expectZeroDiagnostics, getTestTranspile, stagingDir, tempDir } from '../../../testHelpers.spec';
Expand Down Expand Up @@ -193,6 +194,7 @@ describe('ComponentStatement', () => {
end component
`);

// eslint-disable-next-line @typescript-eslint/await-thenable
await testTranspile(program.getFile('components/MainScene.xml'), `
<component name="MainScene" extends="Group">
<script uri="pkg:/components/MainScene.brs" type="text/brightscript" />
Expand Down Expand Up @@ -245,7 +247,7 @@ describe('ComponentStatement', () => {
</component>
`);

await testTranspile(program.getFile('components/MainScene.codebehind.brs'), `
await testTranspile(program.getFile('components/MainScene.codebehind.bs'), `
sub init()
print "MainScene"
end sub
Expand Down Expand Up @@ -277,7 +279,7 @@ describe('ComponentStatement', () => {
</component>
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.brs'), `
await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), `
sub EnableVoiceMode(isEnabled as boolean)
m.top.voiceModeEnabled = isEnabled
end sub
Expand All @@ -299,7 +301,7 @@ describe('ComponentStatement', () => {
</component>
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.brs'), `
await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), `
sub init()
m.isEnabled = true
end sub
Expand All @@ -316,7 +318,7 @@ describe('ComponentStatement', () => {
end component
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.brs'), `
await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), `
sub init()
m.isEnabled = true
'test
Expand All @@ -336,7 +338,7 @@ describe('ComponentStatement', () => {
end component
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.brs'), `
await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), `
sub init()
'test
end sub
Expand All @@ -359,7 +361,7 @@ describe('ComponentStatement', () => {
end component
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.brs'), `
await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), `
sub init()
doSomething()
end sub
Expand All @@ -369,4 +371,140 @@ describe('ComponentStatement', () => {
end sub
`);
});

it('includes xml template', async () => {
program.setFile('components/ZombieKeyboard.bs', `
@template(\`
<label />
\`)
component ZombieKeyboard
end component
`);
expectZeroDiagnostics(program);

expect(
program.getFile('components/ZombieKeyboard.codebehind.bs')
).to.exist;

await testTranspile(program.getFile('components/ZombieKeyboard.xml'), `
<component name="ZombieKeyboard" extends="Group">
<script uri="pkg:/components/ZombieKeyboard.brs" type="text/brightscript" />
<script uri="pkg:/components/ZombieKeyboard.codebehind.brs" type="text/brightscript" />
<script type="text/brightscript" uri="pkg:/source/bslib.brs" />
<children>
<label />
</children>
</component>
`);
});

it('support initialFocus annotation', async () => {
program.setFile('components/ZombieKeyboard.bs', `
@template(\`
<label id="theLabel" />
\`)
@initialFocus(m.theLabel)
component ZombieKeyboard
end component
`);


await testTranspile(program.getFile('components/ZombieKeyboard.xml'), `
<component name="ZombieKeyboard" extends="Group" initialFocus="theLabel">
<script uri="pkg:/components/ZombieKeyboard.brs" type="text/brightscript" />
<script uri="pkg:/components/ZombieKeyboard.codebehind.brs" type="text/brightscript" />
<script type="text/brightscript" uri="pkg:/source/bslib.brs" />
<children>
<label id="theLabel" />
</children>
</component>
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), ``);
});

it('support alwaysNotify annotations on interface fields', async () => {
program.setFile('components/ZombieKeyboard.bs', `
component ZombieKeyboard
@alwaysNotify(true)
public testField as string
end component
`);

await testTranspile(program.getFile('components/ZombieKeyboard.xml'), `
<component name="ZombieKeyboard" extends="Group">
<script uri="pkg:/components/ZombieKeyboard.brs" type="text/brightscript" />
<script uri="pkg:/components/ZombieKeyboard.codebehind.brs" type="text/brightscript" />
<script type="text/brightscript" uri="pkg:/source/bslib.brs" />
<interface>
<field id="testField" type="string" alwaysNotify="true" />
</interface>
</component>
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), ``);
});

it('support alias annotations on interface fields', async () => {
program.setFile('components/ZombieKeyboard.bs', `
@template(\`
<label id="theLabel" />
<label id="theOtherLabel" />
\`)
component ZombieKeyboard
@alias("theLabel.text")
@alias("theOtherLabel.text")
@alias("theOtherLabel.text", "theLabel.text")
@alias("theLabel.text", "theOtherLabel.text")
public testField as string
end component
`);

await testTranspile(program.getFile('components/ZombieKeyboard.xml'), `
<component name="ZombieKeyboard" extends="Group">
<script uri="pkg:/components/ZombieKeyboard.brs" type="text/brightscript" />
<script uri="pkg:/components/ZombieKeyboard.codebehind.brs" type="text/brightscript" />
<script type="text/brightscript" uri="pkg:/source/bslib.brs" />
<interface>
<field id="testField" type="string" alias="theLabel.text, theOtherLabel.text" />
</interface>
<children>
<label id="theLabel" />
<label id="theOtherLabel" />
</children>
</component>
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), ``);
});

it('support onChange annotations on interface fields', async () => {
program.setFile('components/ZombieKeyboard.bs', `
component ZombieKeyboard
@onChange(m.doSomething)
public testField as string

private sub doSomething()
print "do something"
end sub
end component
`);

await testTranspile(program.getFile('components/ZombieKeyboard.codebehind.bs'), `
sub doSomething()
print "do something"
end sub
`);

await testTranspile(program.getFile('components/ZombieKeyboard.xml'), `
<component name="ZombieKeyboard" extends="Group">
<script uri="pkg:/components/ZombieKeyboard.brs" type="text/brightscript" />
<script uri="pkg:/components/ZombieKeyboard.codebehind.brs" type="text/brightscript" />
<script type="text/brightscript" uri="pkg:/source/bslib.brs" />
<interface>
<field id="testField" type="string" onChange="doSomething" />
</interface>
</component>
`);
});
});
Loading