Repository: rokucommunity/brighterscript
Pull request #818 (2023-06-06 13:52:35)

Xml ast refactor
Refactors the XML AST to enable plugins to manipulate during transpile.

There's no noticeable performance hit for most things, but you can see a large boost in transpile performance for xml files!

![image](https://github.com/rokucommunity/brighterscript/assets/2544493/ae0f3326-5913-4365-9848-dedd578b7434)



Patch:
diff --git a/benchmarks/index.ts b/benchmarks/index.ts
index 2c1d1d26..7ca30af5 100644
--- a/benchmarks/index.ts
+++ b/benchmarks/index.ts
@@ -215,7 +215,6 @@ const runner = new Runner(options as any);
 runner.run();
 
 function execSync(command: string, options: ExecSyncOptions = {}) {
-    console.log(command);
     return childProcess.execSync(command, { stdio: 'inherit', cwd: cwd, ...options });
 }
 
diff --git a/scripts/scrape-roku-docs.ts b/scripts/scrape-roku-docs.ts
index a3422c89..6a9afa44 100644
--- a/scripts/scrape-roku-docs.ts
+++ b/scripts/scrape-roku-docs.ts
@@ -298,7 +298,7 @@ class Runner {
                                 const arg = call.args[i];
                                 let paramName = `param${i}`;
                                 if (isVariableExpression(arg)) {
-                                    paramName = arg.getName(ParseMode.BrightScript)
+                                    paramName = arg.getName(ParseMode.BrightScript);
                                 }
                                 signature.params.push({
                                     name: paramName,
@@ -311,7 +311,7 @@ class Runner {
                             component.constructors.push(signature);
                         }
                     } else if (match[1]) {
-                        const signature = this.getConstructorSignature(name, match[1])
+                        const signature = this.getConstructorSignature(name, match[1]);
 
                         if (signature) {
                             component.constructors.push(signature);
@@ -332,7 +332,7 @@ class Runner {
     }
 
     private getConstructorSignature(componentName: string, sourceCode: string) {
-        const foundParamTexts = this.findParamTexts(sourceCode)
+        const foundParamTexts = this.findParamTexts(sourceCode);
 
         if (foundParamTexts && foundParamTexts[0].toLowerCase() === componentName.toLowerCase()) {
             const signature = {
@@ -797,6 +797,7 @@ function deepSearch<T = any>(object, key, predicate): T {
         return object;
     }
 
+    // eslint-disable-next-line @typescript-eslint/prefer-for-of
     for (let i = 0; i < Object.keys(object).length; i++) {
         let value = object[Object.keys(object)[i]];
         if (typeof value === 'object' && value) {
@@ -1078,16 +1079,16 @@ class TokenManager {
     }
 
     /**
-    * Find any `is deprecated` text between the specified items
-    */
+     * Find any `is deprecated` text between the specified items
+     */
     public getDeprecatedDescription(startToken: Token, endToken: Token) {
         const deprecatedDescription = this.find<marked.Tokens.Text>(x => !!/is\s*deprecated/i.exec(x?.text), startToken, endToken)?.text;
         return deprecatedDescription;
     }
 
     /**
-    * Sets `deprecatedDescription` and `isDeprecated` on passed in entity if `deprecated` is mentioned between the two tokens
-    */
+     * Sets `deprecatedDescription` and `isDeprecated` on passed in entity if `deprecated` is mentioned between the two tokens
+     */
     public setDeprecatedData(entity: PossiblyDeprecated, startToken: Token, endToken: Token) {
         entity.deprecatedDescription = this.getDeprecatedDescription(startToken, endToken);
         if (entity.deprecatedDescription) {
diff --git a/src/Program.ts b/src/Program.ts
index c35a0612..f69f4bdb 100644
--- a/src/Program.ts
+++ b/src/Program.ts
@@ -886,7 +886,7 @@ export class Program {
         let funcNames = new Set<string>();
         let currentScope = scope;
         while (isXmlScope(currentScope)) {
-            for (let name of currentScope.xmlFile.ast.component.api?.functions.map((f) => f.name) ?? []) {
+            for (let name of currentScope.xmlFile.ast.componentElement.interfaceElement?.functions.map((f) => f.name) ?? []) {
                 if (!filterName || name === filterName) {
                     funcNames.add(name);
                 }
diff --git a/src/XmlScope.ts b/src/XmlScope.ts
index 8fcb68df..a3501670 100644
--- a/src/XmlScope.ts
+++ b/src/XmlScope.ts
@@ -7,7 +7,7 @@ import type { Program } from './Program';
 import util from './util';
 import { isXmlFile } from './astUtils/reflection';
 import { SGFieldTypes } from './parser/SGTypes';
-import type { SGTag } from './parser/SGTypes';
+import type { SGElement } from './parser/SGTypes';
 
 export class XmlScope extends Scope {
     constructor(
@@ -52,25 +52,26 @@ export class XmlScope extends Scope {
     }
 
     private diagnosticValidateInterface(callableContainerMap: CallableContainerMap) {
-        if (!this.xmlFile.parser.ast?.component?.api) {
+        if (!this.xmlFile.parser.ast?.componentElement?.interfaceElement) {
             return;
         }
-        const { api } = this.xmlFile.parser.ast.component;
+        const iface = this.xmlFile.parser.ast.componentElement.interfaceElement;
+
         //validate functions
-        for (const fun of api.functions) {
-            const name = fun.name;
+        for (const func of iface.functions) {
+            const name = func.name;
             if (!name) {
-                this.diagnosticMissingAttribute(fun, 'name');
+                this.diagnosticMissingAttribute(func, 'name');
             } else if (!callableContainerMap.has(name.toLowerCase())) {
                 this.diagnostics.push({
                     ...DiagnosticMessages.xmlFunctionNotFound(name),
-                    range: fun.getAttribute('name').value.range,
+                    range: func.getAttribute('name').tokens.value.range,
                     file: this.xmlFile
                 });
             }
         }
         //validate fields
-        for (const field of api.fields) {
+        for (const field of iface.fields) {
             const { id, type } = field;
             if (!id) {
                 this.diagnosticMissingAttribute(field, 'id');
@@ -82,18 +83,17 @@ export class XmlScope extends Scope {
             } else if (!SGFieldTypes.includes(type.toLowerCase())) {
                 this.diagnostics.push({
                     ...DiagnosticMessages.xmlInvalidFieldType(type),
-                    range: field.getAttribute('type').value.range,
+                    range: field.getAttribute('type').tokens.value.range,
                     file: this.xmlFile
                 });
             }
         }
     }
 
-    private diagnosticMissingAttribute(tag: SGTag, name: string) {
-        const { text, range } = tag.tag;
+    private diagnosticMissingAttribute(tag: SGElement, name: string) {
         this.diagnostics.push({
-            ...DiagnosticMessages.xmlTagMissingAttribute(text, name),
-            range: range,
+            ...DiagnosticMessages.xmlTagMissingAttribute(tag.tokens.startTagName.text, name),
+            range: tag.tokens.startTagName.range,
             file: this.xmlFile
         });
     }
diff --git a/src/astUtils/creators.ts b/src/astUtils/creators.ts
index 92ae752a..202c88dc 100644
--- a/src/astUtils/creators.ts
+++ b/src/astUtils/creators.ts
@@ -1,9 +1,9 @@
 import type { Range } from 'vscode-languageserver';
 import type { Identifier, Token } from '../lexer/Token';
+import { SGAttribute, SGComponent, SGInterface, SGInterfaceField, SGInterfaceFunction, SGScript } from '../parser/SGTypes';
 import { TokenKind } from '../lexer/TokenKind';
 import type { Expression } from '../parser/AstNode';
-import { LiteralExpression, CallExpression, DottedGetExpression, VariableExpression, FunctionExpression } from '../parser/Expression';
-import type { SGAttribute } from '../parser/SGTypes';
+import { CallExpression, DottedGetExpression, FunctionExpression, LiteralExpression, VariableExpression } from '../parser/Expression';
 import { Block, MethodStatement } from '../parser/Statement';
 
 /**
@@ -177,12 +177,86 @@ export function createCall(callee: Expression, args?: Expression[]) {
  * Create an SGAttribute without any ranges
  */
 export function createSGAttribute(keyName: string, value: string) {
-    return {
-        key: {
-            text: keyName
-        },
-        value: {
-            text: value
-        }
-    } as SGAttribute;
+    return new SGAttribute(
+        { text: keyName },
+        { text: '=' },
+        { text: '"' },
+        { text: value },
+        { text: '"' }
+    );
+}
+
+export function createSGInterfaceField(id: string, attributes: { type?: string; alias?: string; value?: string; onChange?: string; alwaysNotify?: string } = {}) {
+    const attrs = [
+        createSGAttribute('id', id)
+    ];
+    for (let key in attributes) {
+        attrs.push(
+            createSGAttribute(key, attributes[key])
+        );
+    }
+    return new SGInterfaceField(
+        { text: '<' },
+        { text: 'field' },
+        attrs,
+        { text: '/>' }
+    );
+}
+
+export function createSGComponent(name: string, parentName?: string) {
+    const attributes = [
+        createSGAttribute('name', name)
+    ];
+    if (parentName) {
+        attributes.push(
+            createSGAttribute('extends', parentName)
+        );
+    }
+    return new SGComponent(
+        { text: '<' },
+        { text: 'component' },
+        attributes,
+        { text: '>' },
+        [],
+        { text: '</' },
+        { text: 'component' },
+        { text: '>' }
+    );
+}
+
+export function createSGInterfaceFunction(functionName: string) {
+    return new SGInterfaceFunction(
+        { text: '<' },
+        { text: 'function' },
+        [createSGAttribute('name', functionName)],
+        { text: '/>' }
+    );
+}
+
+export function createSGInterface() {
+    return new SGInterface(
+        { text: '<' },
+        { text: 'interface' },
+        [],
+        { text: '>' },
+        [],
+        { text: '</' },
+        { text: 'interface' },
+        { text: '>' }
+    );
+}
+
+export function createSGScript(attributes: { type?: string; uri?: string }) {
+    const attrs = [] as SGAttribute[];
+    for (let key in attributes) {
+        attrs.push(
+            createSGAttribute(key, attributes[key])
+        );
+    }
+    return new SGScript(
+        { text: '<' },
+        { text: 'script' },
+        attrs,
+        { text: '/>' }
+    );
 }
diff --git a/src/astUtils/xml.ts b/src/astUtils/xml.ts
index 11c2df7f..622209e3 100644
--- a/src/astUtils/xml.ts
+++ b/src/astUtils/xml.ts
@@ -1,26 +1,26 @@
-import type { SGChildren, SGComponent, SGField, SGFunction, SGInterface, SGNode, SGScript, SGTag } from '../parser/SGTypes';
+import type { SGChildren, SGComponent, SGInterfaceField, SGInterfaceFunction, SGInterface, SGNode, SGScript, SGElement } from '../parser/SGTypes';
 
-export function isSGComponent(tag: SGTag): tag is SGComponent {
+export function isSGComponent(tag: SGElement): tag is SGComponent {
     return tag?.constructor.name === 'SGComponent';
 }
-export function isSGInterface(tag: SGTag): tag is SGInterface {
+export function isSGInterface(tag: SGElement): tag is SGInterface {
     return tag?.constructor.name === 'SGInterface';
 }
-export function isSGScript(tag: SGTag): tag is SGScript {
+export function isSGScript(tag: SGElement): tag is SGScript {
     return tag?.constructor.name === 'SGScript';
 }
-export function isSGChildren(tag: SGTag): tag is SGChildren {
+export function isSGChildren(tag: SGElement): tag is SGChildren {
     return tag?.constructor.name === 'SGChildren';
 }
-export function isSGField(tag: SGTag): tag is SGField {
+export function isSGInterfaceField(tag: SGElement): tag is SGInterfaceField {
     return tag?.constructor.name === 'SGField';
 }
-export function isSGFunction(tag: SGTag): tag is SGFunction {
+export function isSGInterfaceFunction(tag: SGElement): tag is SGInterfaceFunction {
     return tag?.constructor.name === 'SGFunction';
 }
-export function isSGNode(tag: SGTag): tag is SGNode {
+export function isSGNode(tag: SGElement): tag is SGNode {
     return tag?.constructor.name === 'SGNode';
 }
-export function isSGCustomization(tag: SGTag): tag is SGNode {
-    return isSGNode(tag) && tag.tag?.text?.toLowerCase() === 'customization';
+export function isSGCustomization(tag: SGElement): tag is SGNode {
+    return isSGNode(tag) && tag.tokens.startTagName?.text?.toLowerCase() === 'customization';
 }
diff --git a/src/bscPlugin/BscPlugin.ts b/src/bscPlugin/BscPlugin.ts
index 37776cfd..f85e8ca8 100644
--- a/src/bscPlugin/BscPlugin.ts
+++ b/src/bscPlugin/BscPlugin.ts
@@ -6,6 +6,7 @@ import { CompletionsProcessor } from './completions/CompletionsProcessor';
 import { HoverProcessor } from './hover/HoverProcessor';
 import { BrsFileSemanticTokensProcessor } from './semanticTokens/BrsFileSemanticTokensProcessor';
 import { BrsFilePreTranspileProcessor } from './transpile/BrsFilePreTranspileProcessor';
+import { XmlFilePreTranspileProcessor } from './transpile/XmlFilePreTranspileProcessor';
 import { BrsFileValidator } from './validation/BrsFileValidator';
 import { ProgramValidator } from './validation/ProgramValidator';
 import { ScopeValidator } from './validation/ScopeValidator';
@@ -55,6 +56,8 @@ export class BscPlugin implements CompilerPlugin {
     public beforeFileTranspile(event: BeforeFileTranspileEvent) {
         if (isBrsFile(event.file)) {
             return new BrsFilePreTranspileProcessor(event as any).process();
+        } else if (isXmlFile(event.file)) {
+            return new XmlFilePreTranspileProcessor(event as any).process();
         }
     }
 }
diff --git a/src/bscPlugin/codeActions/CodeActionsProcessor.ts b/src/bscPlugin/codeActions/CodeActionsProcessor.ts
index 3776e338..e56d86c9 100644
--- a/src/bscPlugin/codeActions/CodeActionsProcessor.ts
+++ b/src/bscPlugin/codeActions/CodeActionsProcessor.ts
@@ -98,9 +98,9 @@ export class CodeActionsProcessor {
 
     private addMissingExtends(diagnostic: DiagnosticMessageType<'xmlComponentMissingExtendsAttribute'>) {
         const srcPath = this.event.file.srcPath;
-        const { component } = (this.event.file as XmlFile).parser.ast;
+        const { componentElement } = (this.event.file as XmlFile).parser.ast;
         //inject new attribute after the final attribute, or after the `<component` if there are no attributes
-        const pos = (component.attributes[component.attributes.length - 1] ?? component.tag).range.end;
+        const pos = (componentElement.attributes[componentElement.attributes.length - 1] ?? componentElement.tokens.startTagName).range.end;
         this.event.codeActions.push(
             codeActionUtil.createCodeAction({
                 title: `Extend "Group"`,
diff --git a/src/bscPlugin/transpile/XmlFilePreTranspileProcessor.ts b/src/bscPlugin/transpile/XmlFilePreTranspileProcessor.ts
new file mode 100644
index 00000000..af974e09
--- /dev/null
+++ b/src/bscPlugin/transpile/XmlFilePreTranspileProcessor.ts
@@ -0,0 +1,66 @@
+import { createSGScript } from '../../astUtils/creators';
+import { isXmlFile } from '../../astUtils/reflection';
+import type { XmlFile } from '../../files/XmlFile';
+import type { BeforeFileTranspileEvent } from '../../interfaces';
+import util from '../../util';
+
+export class XmlFilePreTranspileProcessor {
+    public constructor(
+        private event: BeforeFileTranspileEvent<XmlFile>
+    ) {
+    }
+
+    public process() {
+        if (!isXmlFile(this.event.file)) {
+            return;
+        }
+
+        //insert any missing script imports
+        this.injectScriptImports();
+
+        //transform any brighterscript `type` attributes to `brightscript`
+        for (const script of this.event.file.ast?.componentElement?.scriptElements ?? []) {
+            const type = script.getAttribute('type');
+            if (/text\/brighterscript/i.test(type?.value)) {
+                this.event.editor.setProperty(
+                    type,
+                    'value',
+                    type.value.replace(/text\/brighterscript/i, 'text/brightscript')
+                );
+            }
+
+            //replace `.bs` extensions with `.brs`
+            const uri = script.getAttribute('uri');
+            if (/\.bs/i.test(uri?.value)) {
+                this.event.editor.setProperty(
+                    uri,
+                    'value',
+                    uri.value.replace(/\.bs/i, '.brs')
+                );
+            }
+        }
+    }
+
+    /**
+     * Inject any missing scripts into the xml file
+     */
+    private injectScriptImports() {
+        // eslint-disable-next-line @typescript-eslint/dot-notation
+        const extraImportScripts = this.event.file['getMissingImportsForTranspile']().map(uri => {
+            return createSGScript({
+                type: 'text/brightscript',
+                uri: util.sanitizePkgPath(uri.replace(/\.bs$/, '.brs'))
+            });
+        });
+
+        if (extraImportScripts) {
+            //add new scripts after the LAST `<script>` tag that was created explicitly by the user, or at the top of the component if it has no scripts
+            let lastScriptIndex = util.findLastIndex(this.event.file.ast.componentElement.elements, x => x.tokens.startTagName.text.toLowerCase() === 'script');
+            lastScriptIndex = lastScriptIndex >= 0
+                ? lastScriptIndex + 1
+                : 0;
+
+            this.event.editor.arraySplice(this.event.file.ast.componentElement.elements, lastScriptIndex, 0, ...extraImportScripts);
+        }
+    }
+}
diff --git a/src/bscPlugin/validation/XmlFileValidator.ts b/src/bscPlugin/validation/XmlFileValidator.ts
index 44912dce..e1188de9 100644
--- a/src/bscPlugin/validation/XmlFileValidator.ts
+++ b/src/bscPlugin/validation/XmlFileValidator.ts
@@ -12,7 +12,7 @@ export class XmlFileValidator {
 
     public process() {
         util.validateTooDeepFile(this.event.file);
-        if (this.event.file.parser.ast.root) {
+        if (this.event.file.parser.ast.rootElement) {
             this.validateComponent(this.event.file.parser.ast);
         } else {
             //skip empty XML
@@ -20,29 +20,29 @@ export class XmlFileValidator {
     }
 
     private validateComponent(ast: SGAst) {
-        const { root, component } = ast;
-        if (!component) {
+        const { rootElement, componentElement } = ast;
+        if (!componentElement) {
             //not a SG component
             this.event.file.diagnostics.push({
                 ...DiagnosticMessages.xmlComponentMissingComponentDeclaration(),
-                range: root.range,
+                range: rootElement.range,
                 file: this.event.file
             });
             return;
         }
 
         //component name/extends
-        if (!component.name) {
+        if (!componentElement.name) {
             this.event.file.diagnostics.push({
                 ...DiagnosticMessages.xmlComponentMissingNameAttribute(),
-                range: component.tag.range,
+                range: componentElement.tokens.startTagName.range,
                 file: this.event.file
             });
         }
-        if (!component.extends) {
+        if (!componentElement.extends) {
             this.event.file.diagnostics.push({
                 ...DiagnosticMessages.xmlComponentMissingExtendsAttribute(),
-                range: component.tag.range,
+                range: componentElement.tokens.startTagName.range,
                 file: this.event.file
             });
         }
@@ -50,7 +50,7 @@ export class XmlFileValidator {
 
         //catch script imports with same path as the auto-imported codebehind file
         const scriptTagImports = this.event.file.parser.references.scriptTagImports;
-        let explicitCodebehindScriptTag = this.event.file.program.options.autoImportComponentScript === true
+        let explicitCodebehindScriptTag = this.event.program.options.autoImportComponentScript === true
             ? scriptTagImports.find(x => this.event.file.possibleCodebehindPkgPaths.includes(x.pkgPath))
             : undefined;
         if (explicitCodebehindScriptTag) {
@@ -61,5 +61,4 @@ export class XmlFileValidator {
             });
         }
     }
-
 }
diff --git a/src/files/XmlFile.spec.ts b/src/files/XmlFile.spec.ts
index 610a9e50..ac7e5159 100644
--- a/src/files/XmlFile.spec.ts
+++ b/src/files/XmlFile.spec.ts
@@ -41,8 +41,8 @@ describe('XmlFile', () => {
             program.plugins.add({
                 name: 'allows modifying the parsed XML model',
                 afterFileParse: (file) => {
-                    if (isXmlFile(file) && file.parser.ast.root?.attributes?.[0]?.value) {
-                        file.parser.ast.root.attributes[0].value.text = expected;
+                    if (isXmlFile(file) && file.parser.ast.rootElement?.attributes?.[0]?.value) {
+                        file.parser.ast.rootElement.attributes[0].value = expected;
                     }
                 }
             });
@@ -59,13 +59,13 @@ describe('XmlFile', () => {
             program.plugins.add({
                 name: 'allows modifying the parsed XML model',
                 afterFileParse: () => {
-                    let child = file.parser.ast.component.children.children[0];
+                    let child = file.parser.ast.componentElement.childrenElement.elements[0];
                     expect(child.attributes).to.have.lengthOf(4);
-                    child.setAttribute('text', undefined);
-                    expect(child.getAttribute('id').value.text).to.equal('one');
+                    child.setAttributeValue('text', undefined);
+                    expect(child.getAttribute('id').value).to.equal('one');
                     expect(child.attributes).to.have.lengthOf(3);
-                    child.setAttribute('text3', undefined);
-                    expect(child.getAttribute('id').value.text).to.equal('one');
+                    child.setAttributeValue('text3', undefined);
+                    expect(child.getAttribute('id').value).to.equal('one');
                     expect(child.attributes).to.have.lengthOf(2);
                 }
             });
@@ -631,11 +631,11 @@ describe('XmlFile', () => {
                 <?xml version="1.0" encoding="utf-8" ?>
                 <component name="MainScene" extends="Scene">
                     <script type="text/brightscript" uri="pkg:/source/bslib.brs" />
+                    <customization resumehandler="customResume" />
+                    <customization suspendhandler="customSuspend" />
                     <children>
                         <Rectangle width="1920" height="1080" />
                     </children>
-                    <customization resumehandler="customResume" />
-                    <customization suspendhandler="customSuspend" />
                 </component>
             `);
         });
@@ -753,6 +753,62 @@ describe('XmlFile', () => {
             ).to.eql(expected);
         });
 
+        it('injects new scripts near existing scripts', () => {
+            program.setFile(`components/SimpleScene.bs`, `
+                sub b()
+                end sub
+            `);
+
+            testTranspile(trim`
+                <component name="SimpleScene" extends="group">
+                    <script type="text/brightscript" uri="SimpleScene.bs"/>
+                    <children>
+                        <aa id="aa">
+                            <bb id="bb" />
+                        </aa>
+                    </children>
+                </component>
+            `, trim`
+                <component name="SimpleScene" extends="group">
+                    <script type="text/brightscript" uri="SimpleScene.brs" />
+                    <script type="text/brightscript" uri="pkg:/source/bslib.brs" />
+                    <children>
+                        <aa id="aa">
+                            <bb id="bb" />
+                        </aa>
+                    </children>
+                </component>
+            `, 'none', 'components/SimpleScene.xml');
+        });
+
+        it('injects new scripts at top of component when no scripts were present', () => {
+            program.setFile(`components/SimpleScene.bs`, `
+                sub b()
+                end sub
+            `);
+            program.options.autoImportComponentScript = true;
+
+            testTranspile(trim`
+                <component name="SimpleScene" extends="group">
+                    <children>
+                        <aa id="aa">
+                            <bb id="bb" />
+                        </aa>
+                    </children>
+                </component>
+            `, trim`
+                <component name="SimpleScene" extends="group">
+                    <script type="text/brightscript" uri="pkg:/components/SimpleScene.brs" />
+                    <script type="text/brightscript" uri="pkg:/source/bslib.brs" />
+                    <children>
+                        <aa id="aa">
+                            <bb id="bb" />
+                        </aa>
+                    </children>
+                </component>
+            `, 'none', 'components/SimpleScene.xml');
+        });
+
         it('keeps all content of the XML', () => {
             program.setFile(`components/SimpleScene.bs`, `
                 sub b()
diff --git a/src/files/XmlFile.ts b/src/files/XmlFile.ts
index 23ce5a20..a3666b4d 100644
--- a/src/files/XmlFile.ts
+++ b/src/files/XmlFile.ts
@@ -3,25 +3,27 @@ import type { CodeWithSourceMap } from 'source-map';
 import { SourceNode } from 'source-map';
 import type { CompletionItem, Location, Position, Range } from 'vscode-languageserver';
 import { DiagnosticCodeMap, diagnosticCodes } from '../DiagnosticMessages';
-import type { FunctionScope } from '../FunctionScope';
-import type { Callable, BsDiagnostic, File, FileReference, FunctionCall, CommentFlag } from '../interfaces';
+import type { Callable, BsDiagnostic, FileReference, FunctionCall, CommentFlag, BscFile } from '../interfaces';
 import type { Program } from '../Program';
 import util from '../util';
-import SGParser, { rangeFromTokenValue } from '../parser/SGParser';
+import SGParser from '../parser/SGParser';
 import chalk from 'chalk';
 import { Cache } from '../Cache';
 import type { DependencyGraph } from '../DependencyGraph';
 import type { SGToken } from '../parser/SGTypes';
-import { SGScript } from '../parser/SGTypes';
 import { CommentFlagProcessor } from '../CommentFlagProcessor';
 import type { IToken, TokenType } from 'chevrotain';
 import { TranspileState } from '../parser/TranspileState';
+import type { FunctionScope } from '../FunctionScope';
 
 export class XmlFile {
     constructor(
+        /**
+         * The absolute path to the source file on disk (e.g. '/usr/you/projects/RokuApp/source/main.brs' or 'c:/projects/RokuApp/source/main.brs').
+         */
         public srcPath: string,
         /**
-         * The absolute path to the file, relative to the pkg
+         * The absolute path to the file on-device (i.e. 'source/main.brs') without the leading `pkg:/`
          */
         public pkgPath: string,
         public program: Program
@@ -151,7 +153,7 @@ export class XmlFile {
     public fileRange: Range;
 
     /**
-     * A collection of diagnostics related to this file
+     * Diagnostics for this file
      */
     public diagnostics = [] as BsDiagnostic[];
 
@@ -182,9 +184,19 @@ export class XmlFile {
     }
 
     /**
-     * Does this file need to be transpiled?
+     * Does this file need to be transpiled? Auto-calculated based on file contents, but can be overridden by setting the value explicitly
      */
-    public needsTranspiled = false;
+    public get needsTranspiled() {
+        //needsTranspiled should be true if an import is brighterscript
+        return this._needsTranspiled || this.ast?.componentElement?.scriptElements?.some(
+            script => script.type?.indexOf('brighterscript') > 0 || script.uri?.endsWith('.bs')
+        );
+    }
+    public set needsTranspiled(value: boolean) {
+        this._needsTranspiled = value;
+    }
+
+    private _needsTranspiled = false;
 
     /**
      * The AST for this file
@@ -210,13 +222,7 @@ export class XmlFile {
             ...diagnostic,
             file: this
         }));
-
         this.getCommentFlags(this.parser.tokens as any[]);
-
-        //needsTranspiled should be true if an import is brighterscript
-        this.needsTranspiled = this.needsTranspiled || this.ast.component?.scripts?.some(
-            script => script.type?.indexOf('brighterscript') > 0 || script.uri?.endsWith('.bs')
-        );
     }
 
     /**
@@ -238,7 +244,8 @@ export class XmlFile {
                 processor.tryAdd(
                     //remove the close comment symbol
                     token.image.replace(/\-\-\>$/, ''),
-                    rangeFromTokenValue(token)
+                    //technically this range is 3 characters longer due to the removed `-->`, but that probably doesn't matter
+                    this.parser.rangeFromToken(token)
                 );
             }
         }
@@ -336,7 +343,7 @@ export class XmlFile {
     /**
      * Determines if this xml file has a reference to the specified file (or if it's itself)
      */
-    public doesReferenceFile(file: File) {
+    public doesReferenceFile(file: BscFile) {
         return this.cache.getOrAdd(`doesReferenceFile: ${file.pkgPath}`, () => {
             if (file === this) {
                 return true;
@@ -360,6 +367,7 @@ export class XmlFile {
 
     /**
      * Get all available completions for the specified position
+     * @param position the position to get completions
      */
     public getCompletions(position: Position): CompletionItem[] {
         let scriptImport = util.getScriptImportAtPosition(this.scriptTagImports, position);
@@ -417,7 +425,7 @@ export class XmlFile {
      * and only includes the ones that are not found on the parent.
      * If no parent is found, all imports are returned
      */
-    private getMissingImportsForTranspile() {
+    public getMissingImportsForTranspile() {
         let ownImports = this.getAvailableScriptImports();
         //add the bslib path to ownImports, it'll get filtered down below
         ownImports.push(this.program.bslibPkgPath);
@@ -464,27 +472,10 @@ export class XmlFile {
     public transpile(): CodeWithSourceMap {
         const state = new TranspileState(this.srcPath, this.program.options);
 
-        const extraImportScripts = this.getMissingImportsForTranspile().map(uri => {
-            const script = new SGScript();
-            script.uri = util.getRokuPkgPath(uri.replace(/\.bs$/, '.brs'));
-            return script;
-        });
-
         let transpileResult: SourceNode | undefined;
 
-        if (this.needsTranspiled || extraImportScripts.length > 0) {
-            //temporarily add the missing imports as script tags
-            const originalScripts = this.ast.component?.scripts ?? [];
-            this.ast.component.scripts = [
-                ...originalScripts,
-                ...extraImportScripts
-            ];
-
+        if (this.needsTranspiled) {
             transpileResult = new SourceNode(null, null, state.srcPath, this.parser.ast.transpile(state));
-
-            //restore the original scripts array
-            this.ast.component.scripts = originalScripts;
-
         } else if (this.program.options.sourceMap) {
             //emit code as-is with a simple map to the original file location
             transpileResult = util.simpleMap(state.srcPath, this.fileContents);
diff --git a/src/parser/SGParser.spec.ts b/src/parser/SGParser.spec.ts
index 22f7f451..aa45a6ef 100644
--- a/src/parser/SGParser.spec.ts
+++ b/src/parser/SGParser.spec.ts
@@ -1,7 +1,7 @@
 import { expect } from '../chai-config.spec';
 import { Range } from 'vscode-languageserver';
 import { DiagnosticMessages } from '../DiagnosticMessages';
-import { expectZeroDiagnostics, trim } from '../testHelpers.spec';
+import { getTestTranspile, trim } from '../testHelpers.spec';
 import SGParser from './SGParser';
 import { createSandbox } from 'sinon';
 import { Program } from '../Program';
@@ -12,17 +12,23 @@ let sinon = createSandbox();
 describe('SGParser', () => {
 
     let program: Program;
+    const testTranspile = getTestTranspile(() => [program, rootDir]);
 
     beforeEach(() => {
         program = new Program({ rootDir: rootDir, sourceMap: false });
     });
+
     afterEach(() => {
         sinon.restore();
         program.dispose();
     });
 
     it('Parses well formed SG component', () => {
-        const file = program.setFile<XmlFile>('components/file.xml', trim`
+        program.setFile('components/Component1.brs', `
+            sub load()
+            end sub
+        `);
+        const { file } = testTranspile<XmlFile>(trim`
             <?xml version="1.0" encoding="utf-8" ?>
             <component name="ParentScene" extends="GrandparentScene">
                 <interface>
@@ -44,18 +50,8 @@ describe('SGParser', () => {
                         font="font:MediumBoldSystemFont"
                         />
                 </children>
-            </component>`
-        );
-        const { ast } = file.parser;
-        expect(ast.prolog).to.exist;
-        expect(ast.component).to.exist;
-        expect(ast.root).to.equal(ast.component);
-        expectZeroDiagnostics(file);
-
-        const output = file.transpile();
-        expect(
-            output.code.trimEnd()
-        ).to.equal(trim`
+            </component>
+        `, trim`
             <?xml version="1.0" encoding="utf-8" ?>
             <component name="ParentScene" extends="GrandparentScene">
                 <interface>
@@ -73,7 +69,12 @@ describe('SGParser', () => {
                     <Label id="loadingIndicator" text="Loading..." font="font:MediumBoldSystemFont" />
                 </children>
             </component>
-        `);
+        `, undefined, 'components/file.xml');
+
+        const { ast } = file.parser;
+        expect(ast.prologElement).to.exist;
+        expect(ast.componentElement).to.exist;
+        expect(ast.rootElement).to.equal(ast.componentElement);
     });
 
     it('does not crash when missing tag name', () => {
diff --git a/src/parser/SGParser.ts b/src/parser/SGParser.ts
index bf2b5a6c..3d8d68bc 100644
--- a/src/parser/SGParser.ts
+++ b/src/parser/SGParser.ts
@@ -1,10 +1,10 @@
 import type { AttributeCstNode, ContentCstNode, DocumentCstNode, ElementCstNode } from '@xml-tools/parser';
 import * as parser from '@xml-tools/parser';
 import { DiagnosticMessages } from '../DiagnosticMessages';
-import type { Diagnostic, Range } from 'vscode-languageserver';
+import type { Diagnostic } from 'vscode-languageserver';
 import util from '../util';
-import { SGAst, SGProlog, SGChildren, SGComponent, SGField, SGFunction, SGInterface, SGNode, SGScript } from './SGTypes';
-import type { SGTag, SGToken, SGAttribute, SGReferences } from './SGTypes';
+import { SGAst, SGProlog, SGChildren, SGComponent, SGInterfaceField, SGInterfaceFunction, SGInterface, SGNode, SGScript, SGAttribute } from './SGTypes';
+import type { SGElement, SGToken, SGReferences } from './SGTypes';
 import { isSGComponent } from '../astUtils/xml';
 
 export default class SGParser {
@@ -49,28 +49,26 @@ export default class SGParser {
      * Walk the AST to extract references to useful bits of information
      */
     private findReferences() {
-        this._references = emptySGReferences();
+        this._references = this.emptySGReferences();
 
-        const { component } = this.ast;
-        if (!component) {
+        const { componentElement } = this.ast;
+        if (!componentElement) {
             return;
         }
 
-        const nameAttr = component.getAttribute('name');
-        if (nameAttr?.value) {
-            this._references.name = nameAttr.value;
-        }
-        const extendsAttr = component.getAttribute('extends');
-        if (extendsAttr?.value) {
-            this._references.extends = extendsAttr.value;
+        const nameAttr = componentElement.getAttribute('name');
+        this._references.name = nameAttr?.tokens.value;
+        const extendsAttr = componentElement.getAttribute('extends');
+        if (extendsAttr?.tokens.value) {
+            this._references.extends = extendsAttr.tokens.value;
         }
 
-        for (const script of component.scripts) {
+        for (const script of componentElement.scriptElements) {
             const uriAttr = script.getAttribute('uri');
-            if (uriAttr) {
-                const uri = uriAttr.value.text;
+            if (uriAttr?.tokens.value) {
+                const uri = uriAttr.tokens.value.text;
                 this._references.scriptTagImports.push({
-                    filePathRange: uriAttr.value.range,
+                    filePathRange: uriAttr.tokens.value.range,
                     text: uri,
                     pkgPath: util.getPkgPathFromTarget(this.pkgPath, uri)
                 });
@@ -103,11 +101,11 @@ export default class SGParser {
             const token = err.token;
             this.diagnostics.push({
                 ...DiagnosticMessages.xmlGenericParseError(`Syntax error: ${err.message}`),
-                range: !isNaN(token.startLine) ? rangeFromTokens(token) : util.createRange(0, 0, 0, Number.MAX_VALUE)
+                range: !isNaN(token.startLine) ? this.rangeFromToken(token) : util.createRange(0, 0, 0, Number.MAX_VALUE)
             });
         }
 
-        const { prolog, root } = buildAST(cst as any, this.diagnostics);
+        const { prolog, root } = this.buildAST(cst as any);
         if (!root) {
             const token1 = tokenVector[0];
             const token2 = tokenVector[1];
@@ -118,7 +116,7 @@ export default class SGParser {
             ) {
                 this.diagnostics.push({
                     ...DiagnosticMessages.xmlGenericParseError('Syntax error: whitespace found before the XML prolog'),
-                    range: rangeFromTokens(token1)
+                    range: this.rangeFromToken(token1)
                 });
             }
         }
@@ -129,243 +127,265 @@ export default class SGParser {
             if (root) {
                 //error: not a component
                 this.diagnostics.push({
-                    ...DiagnosticMessages.xmlUnexpectedTag(root.tag.text),
-                    range: root.tag.range
+                    ...DiagnosticMessages.xmlUnexpectedTag(root.tokens.startTagName.text),
+                    range: root.tokens.startTagName.range
                 });
             }
             this.ast = new SGAst(prolog, root);
         }
     }
-}
 
-function buildAST(cst: DocumentCstNode, diagnostics: Diagnostic[]) {
-    const { prolog: cstProlog, element } = cst.children;
+    buildAST(cst: DocumentCstNode) {
+        const { prolog: cstProlog, element } = cst.children;
+
+        let prolog: SGProlog;
+        if (cstProlog?.[0]) {
+            const ctx = cstProlog[0].children;
+            prolog = new SGProlog(
+                // <?
+                this.createPartialToken(ctx.XMLDeclOpen[0], 0, 2),
+                // xml
+                this.createPartialToken(ctx.XMLDeclOpen[0], 2, 3),
+                this.mapAttributes(ctx.attribute),
+                // ?>
+                this.mapToken(ctx.SPECIAL_CLOSE[0])
+            );
+        }
 
-    let prolog: SGProlog;
-    if (cstProlog?.[0]) {
-        const ctx = cstProlog[0].children;
-        prolog = new SGProlog(
-            mapToken(ctx.XMLDeclOpen[0]),
-            mapAttributes(ctx.attribute),
-            rangeFromTokens(ctx.XMLDeclOpen[0], ctx.SPECIAL_CLOSE[0])
-        );
-    }
+        let root: SGElement;
+        if (element.length > 0 && element[0]?.children?.Name) {
+            root = this.mapElement(element[0]);
+        }
 
-    let root: SGTag;
-    if (element.length > 0 && element[0]?.children?.Name) {
-        root = mapElement(element[0], diagnostics);
+        return {
+            prolog: prolog,
+            root: root
+        };
     }
 
-    return {
-        prolog: prolog,
-        root: root
-    };
-}
-
-//not exposed by @xml-tools/parser
-interface IToken {
-    image: string;
-    startOffset: number;
-    startLine?: number;
-    startColumn?: number;
-    endOffset?: number;
-    endLine?: number;
-    endColumn?: number;
-}
-
-function mapElement({ children }: ElementCstNode, diagnostics: Diagnostic[]): SGTag {
-    const nameToken = children.Name[0];
-    let range: Range;
-    const selfClosing = !!children.SLASH_CLOSE;
-    if (selfClosing) {
-        const scToken = children.SLASH_CLOSE[0];
-        range = rangeFromTokens(nameToken, scToken);
-    } else {
-        const endToken = children.END?.[0];
-        range = rangeFromTokens(nameToken, endToken);
-    }
-    const name = mapToken(nameToken);
-    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);
-        case 'interface':
-            const interfaceContent = mapElements(content, ['field', 'function'], diagnostics);
-            return new SGInterface(name, interfaceContent, range);
-        case 'field':
-            if (hasElements(content)) {
-                reportUnexpectedChildren(name, diagnostics);
-            }
-            return new SGField(name, attributes, range);
-        case 'function':
-            if (hasElements(content)) {
-                reportUnexpectedChildren(name, diagnostics);
-            }
-            return new SGFunction(name, attributes, range);
-        case 'script':
-            if (hasElements(content)) {
-                reportUnexpectedChildren(name, diagnostics);
-            }
-            const cdata = getCdata(content);
-            return new SGScript(name, attributes, cdata, range);
-        case 'children':
-            const childrenContent = mapNodes(content);
-            return new SGChildren(name, childrenContent, range);
-        default:
-            const nodeContent = mapNodes(content);
-            return new SGNode(name, attributes, nodeContent, range);
+    mapElement({ children }: ElementCstNode): SGElement {
+        const startTagOpen = this.mapToken(children.OPEN[0]);
+        const startTagName = this.mapToken(children.Name[0]);
+        const attributes = this.mapAttributes(children.attribute);
+        const startTagClose = this.mapToken((children.SLASH_CLOSE ?? children.START_CLOSE)?.[0]);
+        const endTagOpen = this.mapToken(children.SLASH_OPEN?.[0]);
+        const endTagName = this.mapToken(children.END_NAME?.[0]);
+        const endTagClose = this.mapToken(children.END?.[0]);
+
+        const content = children.content?.[0];
+
+        let childrenContent: SGElement[];
+
+        switch (startTagName.text) {
+            case 'component':
+                childrenContent = this.mapElements(content, ['interface', 'script', 'children', 'customization']);
+                return new SGComponent(startTagOpen, startTagName, attributes, startTagClose, childrenContent, endTagOpen, endTagName, endTagClose);
+            case 'interface':
+                childrenContent = this.mapElements(content, ['field', 'function']);
+                return new SGInterface(startTagOpen, startTagName, attributes, startTagClose, childrenContent, endTagOpen, endTagName, endTagClose);
+            case 'field':
+                if (this.hasElements(content)) {
+                    this.diagnostics.push({
+                        range: startTagName.range,
+                        ...DiagnosticMessages.xmlUnexpectedChildren(startTagName.text)
+                    });
+                }
+                return new SGInterfaceField(startTagOpen, startTagName, attributes, startTagClose, childrenContent, endTagOpen, endTagName, endTagClose);
+            case 'function':
+                if (this.hasElements(content)) {
+                    this.diagnostics.push({
+                        range: startTagName.range,
+                        ...DiagnosticMessages.xmlUnexpectedChildren(startTagName.text)
+                    });
+                }
+                return new SGInterfaceFunction(startTagOpen, startTagName, attributes, startTagClose, childrenContent, endTagOpen, endTagName, endTagClose);
+            case 'script':
+                if (this.hasElements(content)) {
+                    this.diagnostics.push({
+                        range: startTagName.range,
+                        ...DiagnosticMessages.xmlUnexpectedChildren(startTagName.text)
+                    });
+                }
+                const script = new SGScript(startTagOpen, startTagName, attributes, startTagClose, childrenContent, endTagOpen, endTagName, endTagClose);
+                script.cdata = this.getCdata(content);
+                return script;
+            case 'children':
+                childrenContent = this.mapNodes(content);
+                return new SGChildren(startTagOpen, startTagName, attributes, startTagClose, childrenContent, endTagOpen, endTagName, endTagClose);
+            default:
+                childrenContent = this.mapNodes(content);
+                return new SGNode(startTagOpen, startTagName, attributes, startTagClose, childrenContent, endTagOpen, endTagName, endTagClose);
+        }
     }
-}
-
-function reportUnexpectedChildren(name: SGToken, diagnostics: Diagnostic[]) {
-    diagnostics.push({
-        ...DiagnosticMessages.xmlUnexpectedChildren(name.text),
-        range: name.range
-    });
-}
 
-function mapNode({ children }: ElementCstNode): SGNode {
-    const nameToken = children.Name[0];
-    let range: Range;
-    const selfClosing = !!children.SLASH_CLOSE;
-    if (selfClosing) {
-        const scToken = children.SLASH_CLOSE[0];
-        range = rangeFromTokens(nameToken, scToken);
-    } else {
-        const endToken = children.END?.[0];
-        range = rangeFromTokens(nameToken, endToken);
+    mapNode({ children }: ElementCstNode): SGNode {
+        return new SGNode(
+            //<
+            this.mapToken(children.OPEN[0]),
+            // TagName
+            this.mapToken(children.Name[0]),
+            this.mapAttributes(children.attribute),
+            // > or />
+            this.mapToken((children.SLASH_CLOSE ?? children.START_CLOSE)[0]),
+            this.mapNodes(children.content?.[0]),
+            // </
+            this.mapToken(children.SLASH_OPEN?.[0]),
+            // TagName
+            this.mapToken(children.END_NAME?.[0]),
+            // >
+            this.mapToken(children.END?.[0])
+        );
     }
-    const name = mapToken(nameToken);
-    const attributes = mapAttributes(children.attribute);
-    const content = children.content?.[0];
-    const nodeContent = mapNodes(content);
-    return new SGNode(name, attributes, nodeContent, range);
-}
 
-function mapElements(content: ContentCstNode, allow: string[], diagnostics: Diagnostic[]): SGTag[] {
-    if (!content) {
-        return [];
-    }
-    const { element } = content.children;
-    const tags: SGTag[] = [];
-    if (element) {
-        for (const entry of element) {
-            const name = entry.children.Name?.[0];
-            if (name?.image) {
-                if (allow.includes(name.image)) {
-                    tags.push(mapElement(entry, diagnostics));
+    mapElements(content: ContentCstNode, allow: string[]): SGElement[] {
+        if (!content) {
+            return [];
+        }
+        const { element } = content.children;
+        const tags: SGElement[] = [];
+        if (element) {
+            for (const entry of element) {
+                const name = entry.children.Name?.[0];
+                if (name?.image) {
+                    if (!allow.includes(name.image)) {
+                        //unexpected tag
+                        this.diagnostics.push({
+                            ...DiagnosticMessages.xmlUnexpectedTag(name.image),
+                            range: this.rangeFromToken(name)
+                        });
+                    }
+                    tags.push(this.mapElement(entry));
                 } else {
-                    //unexpected tag
-                    diagnostics.push({
-                        ...DiagnosticMessages.xmlUnexpectedTag(name.image),
-                        range: rangeFromTokens(name)
-                    });
+                    //bad xml syntax...
                 }
-            } else {
-                //bad xml syntax...
             }
         }
+        return tags;
     }
-    return tags;
-}
 
-function mapNodes(content: ContentCstNode): SGNode[] {
-    if (!content) {
-        return [];
+    mapNodes(content: ContentCstNode): SGNode[] {
+        if (!content) {
+            return [];
+        }
+        const { element } = content.children;
+        return element?.map(element => this.mapNode(element));
     }
-    const { element } = content.children;
-    return element?.map(element => mapNode(element));
-}
-
-function hasElements(content: ContentCstNode): boolean {
-    return !!content?.children.element?.length;
-}
 
-function getCdata(content: ContentCstNode): SGToken {
-    if (!content) {
-        return undefined;
+    hasElements(content: ContentCstNode): boolean {
+        return !!content?.children.element?.length;
     }
-    const { CData } = content.children;
-    return mapToken(CData?.[0]);
-}
 
-function mapToken(token: IToken, unquote = false): SGToken {
-    if (!token) {
-        return undefined;
+    getCdata(content: ContentCstNode): SGToken {
+        if (!content) {
+            return undefined;
+        }
+        const { CData } = content.children;
+        return this.mapToken(CData?.[0]);
     }
-    return {
-        text: unquote ? stripQuotes(token.image) : token.image,
-        range: unquote ? rangeFromTokenValue(token) : rangeFromTokens(token)
-    };
-}
 
-function mapAttributes(attributes: AttributeCstNode[]): SGAttribute[] {
-    return attributes?.map(({ children }) => {
-        const key = children.Name[0];
-        const value = children.STRING?.[0];
-
-        let openQuote: SGToken;
-        let closeQuote: SGToken;
-        //capture the leading and trailing quote tokens
-        const match = /^(["']).*?(["'])$/.exec(value?.image);
-        if (match) {
-            const range = rangeFromTokenValue(value);
-            openQuote = {
-                text: match[1],
-                range: util.createRange(range.start.line, range.start.character, range.start.line, range.start.character + 1)
-            };
-            closeQuote = {
-                text: match[1],
-                range: util.createRange(range.end.line, range.end.character - 1, range.end.line, range.end.character)
+    private mapToken(token: IToken): SGToken {
+        if (token) {
+            return {
+                text: token.image,
+                range: this.rangeFromToken(token)
             };
+        } else {
+            return undefined;
+        }
+    }
+
+    /**
+     * Build SGAttributes from the underlying XML parser attributes array
+     */
+    mapAttributes(attributes: AttributeCstNode[]) {
+        // this is a hot function and has been optimized, so don't refactor unless you know what you're doing...
+        const result = [] as SGAttribute[];
+        if (attributes) {
+            // eslint-disable-next-line @typescript-eslint/prefer-for-of
+            for (let i = 0; i < attributes.length; i++) {
+
+                const children = attributes[i].children;
+                const attr = new SGAttribute(this.mapToken(children.Name[0]));
+
+                if (children.EQUALS) {
+                    attr.tokens.equals = this.mapToken(children.EQUALS[0]);
+                }
+                if (children.STRING) {
+                    const valueToken = children.STRING[0];
+                    attr.tokens.openingQuote = this.createPartialToken(valueToken, 0, 1);
+                    attr.tokens.value = this.createPartialToken(valueToken, 1, -2);
+                    attr.tokens.closingQuote = this.createPartialToken(valueToken, -1, 1);
+                }
+                result.push(attr);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Create a partial token from the given token. This only supports single-line tokens.
+     * @param token the offset to start the new token. If negative, subtracts from token.length
+     * @param startOffset the offset to start the new token. If negative, subtracts from token.length
+     * @param length the length of the text to keep. If negative, subtracts from token.length
+     */
+    public createPartialToken(token: IToken, startOffset: number, length?: number) {
+        if (startOffset < 0) {
+            startOffset = token.image.length + startOffset;
+        }
+        if (length === undefined) {
+            length = token.image.length - startOffset;
+        }
+        if (length < 0) {
+            length = token.image.length + length;
         }
         return {
-            key: mapToken(key),
-            openQuote: openQuote,
-            value: mapToken(value, true),
-            closeQuote: closeQuote,
-            range: rangeFromTokens(key, value)
+            text: token.image.substring(startOffset, startOffset + length),
+            //xmltools startLine is 1-based, we need 0-based which is why we subtract 1 below
+            range: util.createRange(
+                token.startLine - 1,
+                token.startColumn - 1 + startOffset,
+                token.endLine - 1,
+                token.startColumn - 1 + (startOffset + length)
+            )
+
         };
-    }) || [];
-}
+    }
 
-//make range from `start` to `end` tokens
-function rangeFromTokens(start: IToken, end?: IToken) {
-    if (!end) {
-        end = start;
+    /**
+     * Create a range based on the xmltools token
+     */
+    public rangeFromToken(token: IToken) {
+        return util.createRange(
+            token.startLine - 1,
+            token.startColumn - 1,
+            token.endLine - 1,
+            token.endColumn
+        );
     }
-    return util.createRange(
-        start.startLine - 1,
-        start.startColumn - 1,
-        end.endLine - 1,
-        end.endColumn
-    );
-}
 
-//make range not including quotes
-export function rangeFromTokenValue(token: IToken) {
-    if (!token) {
-        return undefined;
+    private emptySGReferences(): SGReferences {
+        return {
+            scriptTagImports: []
+        };
     }
-    return util.createRange(
-        token.startLine - 1,
-        token.startColumn,
-        token.endLine - 1,
-        token.endColumn - 1
-    );
 }
 
-function stripQuotes(value: string) {
-    if (value?.length > 1) {
-        return value.substr(1, value.length - 2);
-    }
-    return '';
+//not exposed by @xml-tools/parser
+interface IToken {
+    image: string;
+    startOffset: number;
+    startLine?: number;
+    startColumn?: number;
+    endOffset?: number;
+    endLine?: number;
+    endColumn?: number;
 }
 
-function emptySGReferences(): SGReferences {
-    return {
-        scriptTagImports: []
-    };
+export interface CstNodeLocation {
+    startOffset: number;
+    startLine: number;
+    startColumn?: number;
+    endOffset?: number;
+    endLine?: number;
+    endColumn?: number;
 }
diff --git a/src/parser/SGTypes.ts b/src/parser/SGTypes.ts
index e9ea1b7e..25082adf 100644
--- a/src/parser/SGTypes.ts
+++ b/src/parser/SGTypes.ts
@@ -1,8 +1,13 @@
 import { SourceNode } from 'source-map';
 import type { Range } from 'vscode-languageserver';
-import { createSGAttribute } from '../astUtils/creators';
-import { isSGChildren, isSGCustomization, isSGField, isSGFunction, isSGInterface, isSGScript } from '../astUtils/xml';
-import type { FileReference, TranspileResult } from '../interfaces';
+import { createSGAttribute, createSGInterface, createSGInterfaceField, createSGInterfaceFunction } from '../astUtils/creators';
+import type { FileReference } from '../interfaces';
+import { BooleanType } from '../types/BooleanType';
+import { DynamicType } from '../types/DynamicType';
+import { FloatType } from '../types/FloatType';
+import { IntegerType } from '../types/IntegerType';
+import { LongIntegerType } from '../types/LongIntegerType';
+import { StringType } from '../types/StringType';
 import util from '../util';
 import type { TranspileState } from './TranspileState';
 
@@ -11,189 +16,393 @@ export interface SGToken {
     range?: Range;
 }
 
-export interface SGAttribute {
-    key: SGToken;
-    openQuote?: SGToken;
-    value: SGToken;
-    closeQuote?: SGToken;
-    range?: Range;
+export class SGAttribute {
+    public constructor(
+        key: SGToken,
+        equals?: SGToken,
+        openingQuote?: SGToken,
+        value?: SGToken,
+        closingQuote?: SGToken
+    ) {
+        this.tokens.key = key;
+        this.tokens.equals = equals;
+        this.tokens.openingQuote = openingQuote;
+        this.tokens.value = value;
+        this.tokens.closingQuote = closingQuote;
+    }
+
+    public tokens = {} as {
+        key: SGToken;
+        equals?: SGToken;
+        openingQuote?: SGToken;
+        value?: SGToken;
+        closingQuote?: SGToken;
+    };
+
+    public get key() {
+        return this.tokens.key.text;
+    }
+
+    /**
+     * The value of this attribute. This does not including the opening or closing quote
+     */
+    public get value(): string | undefined {
+        return this.tokens.value?.text;
+    }
+    public set value(val) {
+        if (val === null || val === undefined) {
+            val = '';
+        }
+        if (!this.tokens.equals) {
+            this.tokens.equals = { text: '=' };
+        }
+        if (this.tokens.value) {
+            this.tokens.value.text = val;
+        } else {
+            this.tokens.value = { text: val };
+        }
+    }
+
+    public get range() {
+        if (!this._range) {
+            this._range = util.createBoundingRange(
+                this.tokens.key,
+                this.tokens.equals,
+                this.tokens.openingQuote,
+                this.tokens.value,
+                this.tokens.closingQuote
+            );
+        }
+        return this._range;
+    }
+    private _range = null as Range;
+
+    public transpile(state: TranspileState) {
+        const result = [
+            state.transpileToken(this.tokens.key)
+        ];
+        if (this.tokens.value) {
+            result.push(
+                state.transpileToken(this.tokens.equals, '='),
+                state.transpileToken(this.tokens.openingQuote, '"'),
+                state.transpileToken(this.tokens.value),
+                state.transpileToken(this.tokens.closingQuote, '"')
+            );
+        }
+        return new SourceNode(null, null, null, result);
+    }
+
+    public clone() {
+        return new SGAttribute(
+            { ...this.tokens.key },
+            { ...this.tokens.equals },
+            { ...this.tokens.openingQuote },
+            { ...this.tokens.value },
+            { ...this.tokens.closingQuote }
+        );
+    }
 }
 
-export class SGTag {
+export class SGElement {
 
     constructor(
-        public tag: SGToken,
-        public attributes: SGAttribute[] = [],
-        public range?: Range
-    ) { }
+        startTagOpen: SGToken,
+        startTagName: SGToken,
+        attributes = [] as SGAttribute[],
+        startTagClose?: SGToken,
+        elements = [] as SGElement[],
+        endTagOpen?: SGToken,
+        endTagName?: SGToken,
+        endTagClose?: SGToken
+    ) {
+        this.tokens.startTagOpen = startTagOpen;
+        this.tokens.startTagName = startTagName;
+        this.attributes = attributes;
+        this.tokens.startTagClose = startTagClose;
+        this.elements = elements;
+        this.tokens.endTagOpen = endTagOpen;
+        this.tokens.endTagName = endTagName;
+        this.tokens.endTagClose = endTagClose;
+    }
+
+    public tokens = {} as {
+        /**
+         * The first portion of the startTag. (i.e. `<` or `<?`)
+         */
+        startTagOpen: SGToken;
+        /**
+         * The name of the opening tag (i.e. CoolTag in `<CoolTag>`).
+         */
+        startTagName: SGToken;
+        /**
+         * The last bit of the startTag (i.e. `/>` for self-closing, `?>` for xml prologue, or `>` for tag with children)
+         */
+        startTagClose?: SGToken;
+        /**
+         * The endTag opening char `<`
+         */
+        endTagOpen?: SGToken;
+        /**
+         * The name of the ending tag (i.e. CoolTag in `</CoolTag>`)
+         */
+        endTagName?: SGToken;
+        /**
+         * The endTag closing char `>`
+         */
+        endTagClose?: SGToken;
+    };
+
+    /**
+     * Array of attributes found on this tag
+     */
+    public attributes = [] as SGAttribute[];
+
+    /**
+     * The array of direct children AST elements of this AST node
+     */
+    public elements = [] as SGElement[];
+
+    public get range() {
+        if (!this._range) {
+            this._range = util.createBoundingRange(
+                this.tokens.startTagOpen,
+                this.tokens.startTagName,
+                this.attributes?.[this.attributes?.length - 1],
+                this.tokens.startTagClose,
+                this.elements?.[this.elements?.length - 1],
+                this.tokens.endTagOpen,
+                this.tokens.endTagName,
+                this.tokens.endTagClose
+            );
+        }
+        return this._range;
+    }
+    private _range = null as Range;
+
+    /**
+     * Is this a self-closing tag?
+     */
+    get isSelfClosing() {
+        return this.tokens.startTagClose && this.tokens.startTagClose?.text !== '>';
+    }
 
     get id() {
         return this.getAttributeValue('id');
     }
     set id(value: string) {
-        this.setAttribute('id', value);
-    }
-
-    getAttribute(name: string): SGAttribute | undefined {
-        return this.attributes.find(att => att.key.text.toLowerCase() === name);
+        this.setAttributeValue('id', value);
+    }
+
+    /**
+     * Get the name of this tag.
+     */
+    public get tagName() {
+        return this.tokens.startTagName?.text;
+    }
+
+    /**
+     * Find all direct children by their tag name (case insensitive).
+     * This does not step into children's children.
+     *
+     */
+    public getElementsByTagName<T extends SGElement>(tagName: string) {
+        const result = [] as T[];
+        const lowerTagName = tagName.toLowerCase();
+        for (const el of this.elements) {
+            if (el.tokens.startTagName.text.toLowerCase() === lowerTagName) {
+                result.push(el as T);
+            }
+        }
+        return result as Readonly<Array<T>>;
+    }
+
+    /**
+     * Add a child to the end of the children array
+     */
+    public addChild<T extends SGElement>(tag: T) {
+        this.elements.push(tag);
+        return tag;
+    }
+
+    /**
+     * Remove a child from the children array.
+     * @returns true if node was found and removed, false if the node wasn't there and thus nothing was done
+     */
+    public removeChild(tag: SGElement) {
+        const idx = this.elements.indexOf(tag);
+        if (idx > -1) {
+            this.elements.splice(idx, 1);
+            return true;
+        }
+        return false;
     }
 
-    getAttributeValue(name: string): string | undefined {
-        return this.getAttribute(name)?.value?.text;
+    /**
+     * Does this node have the specified attribute?
+     */
+    public hasAttribute(name: string) {
+        return !!this.getAttribute(name);
     }
 
-    setAttribute(name: string, value: string) {
-        const attr = this.getAttribute(name);
-        if (attr) {
-            if (value) {
-                attr.value = { text: value };
-                attr.range = undefined;
-            } else {
-                this.attributes.splice(this.attributes.indexOf(attr), 1);
+    /**
+     * Get an SGAttribute by its name (case INsensitive)
+     */
+    public getAttribute(name: string): SGAttribute | undefined {
+        const nameLower = name.toLowerCase();
+        for (const attr of this.attributes) {
+            if (attr.tokens.key?.text.toLowerCase() === nameLower) {
+                return attr;
             }
-        } else if (value) {
-            this.attributes.push({
-                key: { text: name },
-                value: { text: value }
-            });
         }
     }
 
-    transpile(state: TranspileState): SourceNode {
-        return new SourceNode(null, null, state.srcPath, [
-            state.indentText,
-            '<',
-            state.transpileToken(this.tag),
-            ...this.transpileAttributes(state, this.attributes),
-            ...this.transpileBody(state)
-        ]);
+    /**
+     * Get an attribute value by its name
+     */
+    public getAttributeValue(name: string): string | undefined {
+        return this.getAttribute(name)?.tokens.value?.text;
     }
 
-    protected transpileBody(state: TranspileState): (string | SourceNode)[] {
-        return [' />\n'];
+    /**
+     * Set an attribute value by its name. If no attribute exists with this name, it is created
+     */
+    public setAttributeValue(name: string, value: string) {
+        if (value === undefined) {
+            this.removeAttribute(name);
+        } else {
+            let attr = this.getAttribute(name);
+            //create an attribute with this name if we don't have one yet
+            if (!attr) {
+                attr = createSGAttribute(name, value);
+                this.attributes.push(
+                    attr
+                );
+            }
+            attr.value = value;
+        }
     }
 
-    protected transpileAttributes(state: TranspileState, attributes: SGAttribute[]): (string | SourceNode)[] {
-        const result = [];
-        for (const attr of attributes) {
-            result.push(
-                ' ',
-                state.transpileToken(attr.key),
-                '=',
-                state.transpileToken(attr.openQuote ?? { text: '"' }),
-                state.transpileToken(attr.value),
-                state.transpileToken(attr.closeQuote ?? { text: '"' })
-            );
+    /**
+     * Remove an attribute by its name. DO NOT USE this to edit AST (use ASTEditor)
+     * @returns true if an attribute was found and removed. False if no attribute was found
+     */
+    public removeAttribute(name: string) {
+        const nameLower = name.toLowerCase();
+        for (let i = 0; i < this.attributes.length; i++) {
+            if (this.attributes[i].key?.toLowerCase() === nameLower) {
+                this.attributes.splice(i, 1);
+                return true;
+            }
         }
-        return result;
+        return false;
     }
-}
 
-export class SGProlog extends SGTag {
-
-    transpile(state: TranspileState) {
-        return new SourceNode(null, null, state.srcPath, [
-            '<?xml',
-            ...this.transpileAttributes(state, this.attributes),
-            ' ?>\n'
+    public transpile(state: TranspileState) {
+        return new SourceNode(null, null, null, [
+            state.transpileToken(this.tokens.startTagOpen, '<'), // <
+            state.transpileToken(this.tokens.startTagName),
+            this.transpileAttributes(state, this.attributes),
+            this.transpileBody(state)
         ]);
     }
-}
-
-export class SGNode extends SGTag {
-
-    constructor(
-        tag: SGToken,
-        attributes?: SGAttribute[],
-        public children: SGNode[] = [],
-        range?: Range
-    ) {
-        super(tag, attributes, range);
-    }
 
-    protected transpileBody(state: TranspileState): (string | SourceNode)[] {
-        if (this.children.length > 0) {
-            const body: (string | SourceNode)[] = ['>\n'];
+    protected transpileBody(state: TranspileState) {
+        if (this.isSelfClosing) {
+            return new SourceNode(null, null, null, [
+                ' ',
+                state.transpileToken(this.tokens.startTagClose, '/>'),
+                state.newline
+            ]);
+        } else {
+            const chunks = [
+                state.transpileToken(this.tokens.startTagClose, '>'),
+                state.newline
+            ];
             state.blockDepth++;
-            body.push(...this.children.map(node => node.transpile(state)));
+            for (const child of this.elements) {
+                chunks.push(
+                    state.indentText,
+                    child.transpile(state)
+                );
+            }
             state.blockDepth--;
-            body.push(state.indentText, '</', this.tag.text, '>\n');
-            return body;
-        } else {
-            return super.transpileBody(state);
+            chunks.push(
+                state.indentText,
+                state.transpileToken(this.tokens.endTagOpen, '</'),
+                state.transpileToken(this.tokens.endTagName ?? this.tokens.startTagName),
+                state.transpileToken(this.tokens.endTagClose, '>'),
+                state.newline
+            );
+            return new SourceNode(null, null, null, chunks);
         }
     }
-}
 
-export class SGChildren extends SGNode {
-
-    constructor(
-        tag: SGToken = { text: 'children' },
-        children: SGNode[] = [],
-        range?: Range
-    ) {
-        super(tag, [], children, range);
+    protected transpileAttributes(state: TranspileState, attributes: SGAttribute[]) {
+        const chunks = [];
+        for (const attr of attributes) {
+            chunks.push(' ', attr.transpile(state));
+        }
+        return new SourceNode(null, null, null, chunks);
     }
 }
 
-export class SGScript extends SGTag {
+export class SGProlog extends SGElement { }
 
-    constructor(
-        tag: SGToken = { text: 'script' },
-        attributes?: SGAttribute[],
-        public cdata?: SGToken,
-        range?: Range
-    ) {
-        super(tag, attributes, range);
-        if (!attributes) {
-            this.type = 'text/brightscript';
-        }
-    }
+export class SGNode extends SGElement { }
+
+export class SGChildren extends SGElement { }
+
+export class SGCustomization extends SGElement { }
+
+export class SGScript extends SGElement {
+
+    public cdata?: SGToken;
 
     get type() {
         return this.getAttributeValue('type');
     }
     set type(value: string) {
-        this.setAttribute('type', value);
+        this.setAttributeValue('type', value);
     }
 
     get uri() {
         return this.getAttributeValue('uri');
     }
     set uri(value: string) {
-        this.setAttribute('uri', value);
+        this.setAttributeValue('uri', value);
     }
 
-    protected transpileBody(state: TranspileState): (string | SourceNode)[] {
+    protected transpileBody(state: TranspileState) {
         if (this.cdata) {
-            return [
+            return new SourceNode(null, null, null, [
                 '>',
                 state.transpileToken(this.cdata),
                 '</',
-                this.tag.text,
-                '>\n'
-            ];
+                this.tokens.startTagName.text,
+                '>',
+                state.newline
+            ]);
         } else {
             return super.transpileBody(state);
         }
     }
 
-    protected transpileAttributes(state: TranspileState, attributes: SGAttribute[]): (string | SourceNode)[] {
+    protected transpileAttributes(state: TranspileState, attributes: SGAttribute[]) {
         const modifiedAttributes = [] as SGAttribute[];
         let foundType = false;
         const bsExtensionRegexp = /\.bs$/i;
 
         for (const attr of attributes) {
-            const lowerKey = attr.key.text.toLowerCase();
-            if (lowerKey === 'uri' && bsExtensionRegexp.exec(attr.value.text)) {
-                modifiedAttributes.push(
-                    util.cloneSGAttribute(attr, attr.value.text.replace(bsExtensionRegexp, '.brs'))
-                );
+            const lowerKey = attr.tokens.key?.text.toLowerCase();
+            if (lowerKey === 'uri' && bsExtensionRegexp.exec(attr.tokens.value?.text)) {
+                const clone = attr.clone();
+                clone.tokens.value.text.replace(bsExtensionRegexp, '.brs');
+                modifiedAttributes.push(clone);
             } else if (lowerKey === 'type') {
                 foundType = true;
-                if (attr.value.text.toLowerCase().endsWith('brighterscript')) {
+                if (attr.tokens.value?.text.toLowerCase().endsWith('brighterscript')) {
                     modifiedAttributes.push(
-                        util.cloneSGAttribute(attr, 'text/brightscript')
+                        attr.clone()
                     );
                 } else {
                     modifiedAttributes.push(attr);
@@ -211,108 +420,173 @@ export class SGScript extends SGTag {
     }
 }
 
-export class SGField extends SGTag {
-
-    constructor(
-        tag: SGToken = { text: 'field' },
-        attributes: SGAttribute[] = [],
-        range?: Range
-    ) {
-        super(tag, attributes, range);
-    }
+export class SGInterfaceField extends SGElement {
 
     get type() {
         return this.getAttributeValue('type');
     }
     set type(value: string) {
-        this.setAttribute('type', value);
+        this.setAttributeValue('type', value);
     }
 
     get alias() {
         return this.getAttributeValue('alias');
     }
     set alias(value: string) {
-        this.setAttribute('alias', value);
+        this.setAttributeValue('alias', value);
     }
 
     get value() {
         return this.getAttributeValue('value');
     }
     set value(value: string) {
-        this.setAttribute('value', value);
+        this.setAttributeValue('value', value);
     }
 
     get onChange() {
         return this.getAttributeValue('onChange');
     }
     set onChange(value: string) {
-        this.setAttribute('onChange', value);
+        this.setAttributeValue('onChange', value);
     }
 
     get alwaysNotify() {
         return this.getAttributeValue('alwaysNotify');
     }
     set alwaysNotify(value: string) {
-        this.setAttribute('alwaysNotify', value);
+        this.setAttributeValue('alwaysNotify', value);
     }
 }
 
-export const SGFieldTypes = [
-    'integer', 'int', 'longinteger', 'float', 'string', 'str', 'boolean', 'bool',
-    'vector2d', 'color', 'time', 'uri', 'node', 'floatarray', 'intarray', 'boolarray',
-    'stringarray', 'vector2darray', 'colorarray', 'timearray', 'nodearray', 'assocarray',
-    'array', 'roarray', 'rect2d', 'rect2darray'
-];
-
-export class SGFunction extends SGTag {
+export enum SGFieldType {
+    integer = 'integer',
+    int = 'int',
+    longinteger = 'longinteger',
+    float = 'float',
+    string = 'string',
+    str = 'str',
+    boolean = 'boolean',
+    bool = 'bool',
+    vector2d = 'vector2d',
+    color = 'color',
+    time = 'time',
+    uri = 'uri',
+    node = 'node',
+    floatarray = 'floatarray',
+    intarray = 'intarray',
+    boolarray = 'boolarray',
+    stringarray = 'stringarray',
+    vector2darray = 'vector2darray',
+    colorarray = 'colorarray',
+    timearray = 'timearray',
+    nodearray = 'nodearray',
+    assocarray = 'assocarray',
+    array = 'array',
+    roarray = 'roarray',
+    rect2d = 'rect2d',
+    rect2darray = 'rect2darray'
+}
+export const SGFieldTypes = Object.keys(SGFieldType);
 
-    constructor(
-        tag: SGToken = { text: 'function' },
-        attributes: SGAttribute[] = [],
-        range?: Range
-    ) {
-        super(tag, attributes, range);
+export function getBscTypeFromSGFieldType(sgFieldType: string) {
+    switch (sgFieldType) {
+        case SGFieldType.integer:
+        case SGFieldType.int: {
+            return new IntegerType();
+        }
+        case SGFieldType.longinteger: {
+            return new LongIntegerType();
+        }
+        case SGFieldType.float: {
+            return new FloatType();
+        }
+        case SGFieldType.string:
+        case SGFieldType.str: {
+            return new StringType();
+        }
+        case SGFieldType.boolean:
+        case SGFieldType.bool: {
+            return new BooleanType();
+        }
+        default: {
+            return new DynamicType();
+        }
     }
+}
 
+export class SGInterfaceFunction extends SGElement {
     get name() {
         return this.getAttributeValue('name');
     }
     set name(value: string) {
-        this.setAttribute('name', value);
+        this.setAttributeValue('name', value);
     }
 }
 
-export class SGInterface extends SGTag {
+export type SGInterfaceMember = SGInterfaceField | SGInterfaceFunction;
 
-    fields: SGField[] = [];
-    functions: SGFunction[] = [];
+export class SGInterface extends SGElement {
+    public get fields() {
+        return this.getElementsByTagName<SGInterfaceField>('field');
+    }
 
-    constructor(
-        tag: SGToken = { text: 'interface' },
-        content?: SGTag[],
-        range?: Range
-    ) {
-        super(tag, [], range);
-        if (content) {
-            for (const tag of content) {
-                if (isSGField(tag)) {
-                    this.fields.push(tag);
-                } else if (isSGFunction(tag)) {
-                    this.functions.push(tag);
-                }
+    public get functions() {
+        return this.getElementsByTagName<SGInterfaceFunction>('function');
+    }
+
+    public get members() {
+        const result = [] as Array<SGInterfaceMember>;
+        for (const node of this.elements) {
+            const tagName = node.tagName?.toLowerCase();
+            if (tagName === 'field' || tagName === 'function') {
+                result.push(node as SGInterfaceMember);
             }
         }
+        return result as Readonly<typeof result>;
     }
 
-    getField(id: string) {
+    /**
+     * Check if there's an SGField with the specified name
+     */
+    public hasField(id: string) {
+        for (const node of this.elements) {
+            const tagName = node.tagName?.toLowerCase();
+            if (tagName === 'field' && (node as SGInterfaceField).id === id) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Check if there's an SGFunction with the specified name
+     */
+    public hasFunction(name: string) {
+        for (const node of this.elements) {
+            const tagName = node.tagName?.toLowerCase();
+            if (tagName === 'function' && (node as SGInterfaceFunction).name === name) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Find a field by its ID
+     */
+    public getField(id: string) {
         return this.fields.find(field => field.id === id);
     }
-    setField(id: string, type: string, onChange?: string, alwaysNotify?: boolean, alias?: string) {
+
+    /**
+     * Set the value of a field. Creates a new field if one does not already exist with this ID
+     */
+    public setField(id: string, type: string, onChange?: string, alwaysNotify?: boolean, alias?: string) {
         let field = this.getField(id);
         if (!field) {
-            field = new SGField();
-            field.id = id;
-            this.fields.push(field);
+            field = this.addChild(
+                createSGInterfaceField(id)
+            );
         }
         field.type = type;
         field.onChange = onChange;
@@ -322,98 +596,260 @@ export class SGInterface extends SGTag {
             field.alwaysNotify = alwaysNotify ? 'true' : 'false';
         }
         field.alias = alias;
+        return field;
+    }
+
+    /**
+     * Remove a field from the interface
+     * @returns true if a field was found and removed. Returns false if no field was found with that name
+     */
+    public removeField(id: string) {
+        for (let i = 0; i < this.elements.length; i++) {
+            const node = this.elements[i];
+            if (node.tagName?.toLowerCase() === 'field' && node.id === id) {
+                this.elements.splice(i, 1);
+                return true;
+            }
+        }
+        return false;
     }
 
-    getFunction(name: string) {
-        return this.functions.find(field => field.name === name);
+    /**
+     * Get the interface function with the specified name
+     */
+    public getFunction(name: string) {
+        return this.functions.find(func => func.name === name);
     }
-    setFunction(name: string) {
+
+    /**
+     * Add or replace a function on the interface
+     */
+    public setFunction(name: string) {
         let func = this.getFunction(name);
         if (!func) {
-            func = new SGFunction();
-            func.name = name;
-            this.functions.push(func);
-        }
-    }
-
-    protected transpileBody(state: TranspileState): (string | SourceNode)[] {
-        const body: (string | SourceNode)[] = ['>\n'];
-        state.blockDepth++;
-        if (this.fields.length > 0) {
-            body.push(...this.fields.map(node => node.transpile(state)));
+            func = this.addChild(
+                createSGInterfaceFunction(name)
+            );
         }
-        if (this.functions.length > 0) {
-            body.push(...this.functions.map(node => node.transpile(state)));
+        return func;
+    }
+
+    /**
+     * Remove a function from the interface
+     * @returns true if a function was found and removed. Returns false if no function was found with that name
+     */
+    public removeFunction(name: string) {
+        for (let i = 0; i < this.elements.length; i++) {
+            const node = this.elements[i];
+            if (node.tagName?.toLowerCase() === 'function' && node.getAttributeValue('name') === name) {
+                this.elements.splice(i, 1);
+                return true;
+            }
         }
-        state.blockDepth--;
-        body.push(state.indentText, '</', this.tag.text, '>\n');
-        return body;
+        return false;
     }
 }
 
-export class SGComponent extends SGTag {
-    constructor(
-        tag: SGToken = { text: 'component' },
-        attributes?: SGAttribute[],
-        content?: SGTag[],
-        range?: Range
-    ) {
-        super(tag, attributes, range);
-        if (content) {
-            for (const tag of content) {
-                if (isSGInterface(tag)) {
-                    this.api = tag;
-                } else if (isSGScript(tag)) {
-                    this.scripts.push(tag);
-                } else if (isSGChildren(tag)) {
-                    this.children = tag;
-                } else if (isSGCustomization(tag)) {
-                    this.customizations.push(tag);
-                }
-            }
+/**
+ * The `<component>` element in SceneGraph. Not to be confused about usages of components like `<Rectangle>`, those are considered `SGNode` instances.
+ */
+export class SGComponent extends SGElement {
+
+    /**
+     * Get all the <Field> and <Function> elements across all <Interface> nodes in this component
+     */
+    public get interfaceMembers() {
+        const members = [] as Array<SGInterfaceMember>;
+        for (const ifaceNode of this.getElementsByTagName<SGInterface>('interface')) {
+            members.push(
+                ...ifaceNode.members
+            );
         }
+        return members as Readonly<typeof members>;
     }
 
-    public api: SGInterface;
+    public get scriptElements() {
+        return this.getElementsByTagName<SGScript>('script');
+    }
 
-    public scripts: SGScript[] = [];
+    /**
+     * Get the <interface> element from this component (if present), or undefined if not.
+     * NOTE: Roku supports and merges multiple <interface> elements in a component, but this
+     * property points to the FIRST one. If you need to check whether a member exists,
+     * look through `this.interfaceMemebers` instead.
+     */
+    public get interfaceElement(): SGInterface | undefined {
+        return this.getElementsByTagName<SGInterface>('interface')[0];
+    }
 
-    public children: SGChildren;
+    /**
+     * Get the `<children>` element of this component. (not to be confused with the AST `childTags` property).
+     * If there are multiope `<children>` elements, this function will return the last `<children>` tag because that's what Roku devices do.
+     */
+    public get childrenElement() {
+        const children = this.getElementsByTagName<SGChildren>('children');
+        return children[children.length - 1];
+    }
 
-    public customizations: SGNode[] = [];
+    public get customizationElements() {
+        return this.getElementsByTagName<SGCustomization>('customization');
+    }
 
+    /**
+     * Specifies the name of the component, that allows you to create the component in your application.
+     * For example, if the name of the component is `CastMemberInfo`, you could create instances of the component declaratively
+     * in a child node element of a component `<children>` element (`<CastMemberInfo/>`), or using BrightScript in a `<script>`
+     * element (`createObject("roSGNode","CastMemberInfo")`).
+     *
+     * The name attribute is case-sensitive. You cannot successfully create or declare a component unless the component name exactly
+     *  matches the name attribute, including case. Also be aware that two components with the exact same name in the same application
+     * components directory will have undefined and generally undesirable results if you attempt to create a component object with that name in the application.
+     */
     get name() {
         return this.getAttributeValue('name');
     }
     set name(value: string) {
-        this.setAttribute('name', value);
+        this.setAttributeValue('name', value);
     }
 
+    /**
+     * Specifies the name of the built-in or extended SceneGraph scene or node class whose functionality is extended by this component.
+     *
+     * For example, `extends="Group"` specifies that the component has all of the functionality of the Group node class (it can have child nodes, has translation/scale/rotation fields, and so forth).
+     *
+     * By default, a component extends the Group node class.
+     */
     get extends() {
         return this.getAttributeValue('extends');
     }
     set extends(value: string) {
-        this.setAttribute('extends', value);
+        this.setAttributeValue('extends', value);
     }
 
-    protected transpileBody(state: TranspileState): (string | SourceNode)[] {
-        const body: (string | SourceNode)[] = ['>\n'];
-        state.blockDepth++;
-        if (this.api) {
-            body.push(this.api.transpile(state));
+    /**
+     * Specifies the ID of a node declared in the XML file to have the initial remote control focus when the component is instantiated.
+     */
+    get initialFocus() {
+        return this.getAttributeValue('initialFocus');
+    }
+    set initialFocus(value: string) {
+        this.setAttributeValue('initialFocus', value);
+    }
+
+    /**
+     * Specifies the version of the SceneGraph API. The default is 1.0 if not specified.
+     */
+    get version() {
+        return this.getAttributeValue('version');
+    }
+    set version(value: string) {
+        this.setAttributeValue('version', value);
+    }
+
+    /**
+     * Does the specified field exist in the component interface?
+     */
+    public hasInterfaceField(id: string) {
+        for (const ifaceNode of this.getElementsByTagName<SGInterface>('interface')) {
+            if (ifaceNode.hasField(id)) {
+                return true;
+            }
         }
-        if (this.scripts.length > 0) {
-            body.push(...this.scripts.map(node => node.transpile(state)));
+        return false;
+    }
+
+    /**
+     * Does the specified function exist in the component interface?
+     */
+    public hasInterfaceFunction(name: string) {
+        for (const ifaceNode of this.getElementsByTagName<SGInterface>('interface')) {
+            if (ifaceNode.hasFunction(name)) {
+                return true;
+            }
         }
-        if (this.children) {
-            body.push(this.children.transpile(state));
+        return false;
+    }
+
+    /**
+     * Get an interface field with the specified name
+     */
+    public getInterfaceField(name: string): SGInterfaceField | undefined {
+        for (const ifaceNode of this.getElementsByTagName<SGInterface>('interface')) {
+            const field = ifaceNode.getField(name);
+            if (field) {
+                return field;
+            }
+        }
+    }
+
+    /**
+     * Return the first SGInterface node found, or insert a new one then return it
+     */
+    private ensureInterfaceNode(): SGInterface {
+        for (const el of this.elements) {
+            if (el.tokens.startTagName.text.toLowerCase() === 'interface') {
+                return el as SGInterface;
+            }
+        }
+        return this.addChild(
+            createSGInterface()
+        );
+    }
+
+    /**
+     * Create or update a <field> interface element.
+     * This will create a new `<interface>` element if there are none on the component already
+     */
+    public setInterfaceField(id: string, type: string, onChange?: string, alwaysNotify?: boolean, alias?: string) {
+        let ifaceNode = this.ensureInterfaceNode();
+        return ifaceNode.setField(id, type, onChange, alwaysNotify, alias);
+    }
+
+    /**
+     * Create or update a <function> interface element.
+     * This will create a new `<interface>` element if there are none on the component already
+     */
+    public setInterfaceFunction(name: string) {
+        let ifaceNode = this.ensureInterfaceNode();
+        return ifaceNode.setFunction(name);
+    }
+
+    /**
+     * Remove an interface field.
+     * @returns true if a field was found and removed. Returns false if no field was found with that name
+     */
+    public removeInterfaceField(id: string) {
+        for (const ifaceNode of this.getElementsByTagName<SGInterface>('interface')) {
+            if (ifaceNode.removeField(id)) {
+                return true;
+            }
         }
-        if (this.customizations.length > 0) {
-            body.push(...this.customizations.map(node => node.transpile(state)));
+        return false;
+    }
+
+    /**
+     * Get an interface field with the specified name
+     */
+    public getInterfaceFunction(name: string): SGInterfaceFunction | undefined {
+        for (const ifaceNode of this.getElementsByTagName<SGInterface>('interface')) {
+            const func = ifaceNode.getFunction(name);
+            if (func) {
+                return func;
+            }
         }
-        state.blockDepth--;
-        body.push(state.indentText, '</', this.tag.text, '>\n');
-        return body;
+    }
+
+    /**
+     * Remove an interface function.
+     * @returns true if a function was found and removed. Returns false if no function was found with that name
+     */
+    public removeInterfaceFunction(name: string) {
+        for (const ifaceNode of this.getElementsByTagName<SGInterface>('interface')) {
+            if (ifaceNode.removeFunction(name)) {
+                return true;
+            }
+        }
+        return false;
     }
 }
 
@@ -426,22 +862,26 @@ export interface SGReferences {
 export class SGAst {
 
     constructor(
-        public prolog?: SGProlog,
-        public root?: SGTag,
-        public component?: SGComponent
+        public prologElement?: SGProlog,
+        public rootElement?: SGElement,
+        public componentElement?: SGComponent
     ) {
     }
 
-    transpile(state: TranspileState) {
-        const chunks = [] as TranspileResult;
+    public transpile(state: TranspileState): SourceNode {
+        const chunks = [] as SourceNode[];
         //write XML prolog
-        if (this.prolog) {
-            chunks.push(this.prolog.transpile(state));
+        if (this.prologElement) {
+            chunks.push(
+                this.prologElement.transpile(state)
+            );
         }
-        if (this.component) {
+        if (this.componentElement) {
             //write content
-            chunks.push(this.component.transpile(state));
+            chunks.push(
+                this.componentElement.transpile(state)
+            );
         }
-        return chunks;
+        return new SourceNode(null, null, null, chunks);
     }
 }
diff --git a/src/parser/TranspileState.ts b/src/parser/TranspileState.ts
index 025f76d7..800ec23e 100644
--- a/src/parser/TranspileState.ts
+++ b/src/parser/TranspileState.ts
@@ -84,9 +84,12 @@ export class TranspileState {
     /**
      * Create a SourceNode from a token, accounting for missing range and multi-line text
      */
-    public transpileToken(token: { range?: Range; text: string }) {
+    public transpileToken(token: { range?: Range; text: string }, defaultValue?: string) {
+        if (!token && defaultValue !== undefined) {
+            return new SourceNode(null, null, null, defaultValue);
+        }
         if (!token.range) {
-            return token.text;
+            return new SourceNode(null, null, null, token.text);
         }
         //split multi-line text
         if (token.range.end.line > token.range.start.line) {
diff --git a/src/util.ts b/src/util.ts
index 2241224f..9bc998ae 100644
--- a/src/util.ts
+++ b/src/util.ts
@@ -29,7 +29,6 @@ import { TokenKind } from './lexer/TokenKind';
 import { isAssignmentStatement, isBrsFile, isCallExpression, isCallfuncExpression, isDottedGetExpression, isExpression, isFunctionParameterExpression, isGroupingExpression, isIndexedGetExpression, isLiteralExpression, isNewExpression, isTypeExpression, isVariableExpression, isXmlAttributeGetExpression, isXmlFile } from './astUtils/reflection';
 import { WalkMode } from './astUtils/visitors';
 import { SourceNode } from 'source-map';
-import type { SGAttribute } from './parser/SGTypes';
 import * as requireRelative from 'require-relative';
 import type { BrsFile } from './files/BrsFile';
 import type { XmlFile } from './files/XmlFile';
@@ -1180,23 +1179,6 @@ export class Util {
         return new SourceNode(null, null, source, chunks);
     }
 
-    /**
-     * Creates a new SGAttribute object, but keeps the existing Range references (since those shouldn't ever get changed directly)
-     */
-    public cloneSGAttribute(attr: SGAttribute, value: string) {
-        return {
-            key: {
-                text: attr.key.text,
-                range: attr.range
-            },
-            value: {
-                text: value,
-                range: attr.value.range
-            },
-            range: attr.range
-        } as SGAttribute;
-    }
-
     /**
      * Converts a path into a standardized format (drive letter to lower, remove extra slashes, use single slash type, resolve relative parts, etc...)
      */
@@ -1496,6 +1478,17 @@ export class Util {
         }
     }
 
+    /**
+     * Find the index of the last item in the array that matches.
+     */
+    public findLastIndex<T>(array: T[], matcher: (T) => boolean) {
+        for (let i = array.length - 1; i >= 0; i--) {
+            if (matcher(array[i])) {
+                return i;
+            }
+        }
+    }
+
     public processTypeChain(typeChain: TypeChainEntry[]): TypeChainProcessResult {
         let fullChainName = '';
         let fullErrorName = '';


Test patch:
diff --git a/src/files/tests/imports.spec.ts b/src/files/tests/imports.spec.ts
index 2c2af965..035679d8 100644
--- a/src/files/tests/imports.spec.ts
+++ b/src/files/tests/imports.spec.ts
@@ -174,13 +174,6 @@ describe('import statements', () => {
     });
 
     it('adds brs imports to xml file during transpile', () => {
-        //create child component
-        let component = program.setFile('components/ChildScene.xml', trim`
-            <?xml version="1.0" encoding="utf-8" ?>
-            <component name="ChildScene" extends="ParentScene">
-                <script type="text/brightscript" uri="pkg:/source/lib.bs" />
-            </component>
-        `);
         program.setFile('source/lib.bs', `
             import "stringOps.brs"
             function toLower(strVal as string)
@@ -189,18 +182,22 @@ describe('import statements', () => {
         `);
         program.setFile('source/stringOps.brs', `
             function StringToLower(strVal as string)
-                return isInt(strVal)
+                return LCase(strVal)
             end function
         `);
-        program.validate();
-        expect(trimMap(component.transpile().code)).to.equal(trim`
+        testTranspile(trim`
+            <?xml version="1.0" encoding="utf-8" ?>
+            <component name="ChildScene" extends="ParentScene">
+                <script type="text/brightscript" uri="pkg:/source/lib.bs" />
+            </component>
+        `, trim`
             <?xml version="1.0" encoding="utf-8" ?>
             <component name="ChildScene" extends="ParentScene">
                 <script type="text/brightscript" uri="pkg:/source/lib.brs" />
                 <script type="text/brightscript" uri="pkg:/source/stringOps.brs" />
                 <script type="text/brightscript" uri="pkg:/source/bslib.brs" />
             </component>
-        `);
+        `, null, 'components/ChildScene.xml');
     });
 
     it('shows diagnostic for missing file in import', () => {
diff --git a/src/testHelpers.spec.ts b/src/testHelpers.spec.ts
index bec7b6db..7fe5dbfa 100644
--- a/src/testHelpers.spec.ts
+++ b/src/testHelpers.spec.ts
@@ -255,10 +255,10 @@ function getTestFileAction(
     action: (file: BscFile) => CodeWithSourceMap,
     scopeGetter: () => [program: Program, rootDir: string]
 ) {
-    return function testFileAction(source: string, expected?: string, formatType: 'trim' | 'none' = 'trim', pkgPath = 'source/main.bs', failOnDiagnostic = true) {
+    return function testFileAction<TFile extends BscFile=BrsFile>(source: string, expected?: string, formatType: 'trim' | 'none' = 'trim', pkgPath = 'source/main.bs', failOnDiagnostic = true) {
         let [program, rootDir] = scopeGetter();
         expected = expected ? expected : source;
-        let file = program.setFile<BrsFile>({ src: s`${rootDir}/${pkgPath}`, dest: pkgPath }, source);
+        let file = program.setFile<TFile>({ src: s`${rootDir}/${pkgPath}`, dest: pkgPath }, source);
         program.validate();
         if (failOnDiagnostic !== false) {
             expectZeroDiagnostics(program);