Skip to content

Commit 764203b

Browse files
adamintCopilot
andcommitted
Rewrite playground AppHost import parser for multi-line chains
- Preprocess code: strip comments, preprocessor directives, normalize whitespace - Split into semicolon-delimited statements before parsing (handles multi-line chains) - AddDatabase chained after builder calls now correctly associates with parent - WithReference/WaitFor found anywhere in a statement, not just at chain start - Add missing METHOD_TO_TYPE entries: AddYarp, AddNpmApp, AddPythonApp, AddConnectionString, AddCSharpApp, AddElasticsearch, AddMySql, AddOracle, AddNats - Support path-based AddProject with @"..." verbatim strings - Add import feedback: show count on success, error message on failure - Add importFeedback UI banner after import completes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7ba797c commit 764203b

1 file changed

Lines changed: 142 additions & 50 deletions

File tree

AspireAcademy.Web/src/pages/PlaygroundPage.tsx

Lines changed: 142 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -677,73 +677,153 @@ function makeCSharpScaffold(name: string, refs: PlaygroundResource[]): ProjectSc
677677
const METHOD_TO_TYPE: Record<string, ResourceType> = {
678678
AddPostgres: 'postgres', AddRedis: 'redis', AddSqlServer: 'sqlserver',
679679
AddMongoDB: 'mongodb', AddRabbitMQ: 'rabbitmq', AddKafka: 'kafka',
680-
AddProject: 'project', AddContainer: 'container',
681-
AddViteApp: 'npmapp', AddJavaScriptApp: 'npmapp', AddNodeApp: 'npmapp',
682-
AddUvicornApp: 'pythonapp', AddUvApp: 'pythonapp',
680+
AddProject: 'project', AddCSharpApp: 'project',
681+
AddContainer: 'container',
682+
AddNpmApp: 'npmapp', AddViteApp: 'npmapp', AddJavaScriptApp: 'npmapp', AddNodeApp: 'npmapp',
683+
AddPythonApp: 'pythonapp', AddUvicornApp: 'pythonapp', AddUvApp: 'pythonapp',
683684
AddAzureStorage: 'azurestorage', AddAzureKeyVault: 'keyvault',
684-
AddParameter: 'parameter',
685+
AddParameter: 'parameter', AddConnectionString: 'parameter',
686+
AddYarp: 'container', AddElasticsearch: 'container',
687+
AddMySql: 'container', AddOracle: 'container', AddNats: 'container',
685688
};
686689

690+
function preprocessCode(code: string): string {
691+
return code
692+
.replace(/\/\/.*$/gm, '')
693+
.replace(/\/\*[\s\S]*?\*\//g, '')
694+
.replace(/#(?:if|else|endif|pragma|region|endregion).*$/gm, '')
695+
.replace(/\r\n/g, '\n');
696+
}
697+
698+
function splitStatements(code: string): string[] {
699+
const flat = code.replace(/\n/g, ' ').replace(/\s+/g, ' ');
700+
return flat.split(';').map(s => s.trim()).filter(Boolean);
701+
}
702+
687703
function parseAppHostCode(code: string): PlaygroundResource[] {
688704
const resources: PlaygroundResource[] = [];
689705
const varToId = new Map<string, string>();
706+
// Map varName to the statement it was defined in (for attribute scanning)
707+
const varToStmt = new Map<string, string>();
690708

691-
// Match: var <varName> = builder.<Method>("<name>"...)
692-
const addRegex = /(?:var|const)\s+(\w+)\s*=\s*builder\.(\w+)\s*(?:<[^>]+>)?\s*\("([^"]+)"(?:\s*,\s*"([^"]*)")?/g;
693-
let match;
694-
while ((match = addRegex.exec(code)) !== null) {
695-
const [, varName, method, name, secondArg] = match;
696-
const type = METHOD_TO_TYPE[method];
697-
if (!type) continue;
698-
699-
const id = makeId();
700-
varToId.set(varName, id);
701-
702-
const r = makeResource({ id, type, name });
703-
if (type === 'container' && secondArg) r.image = secondArg;
704-
if ((type === 'npmapp') && secondArg) r.projectPath = secondArg;
705-
if (type === 'pythonapp' && secondArg) r.projectPath = secondArg;
706-
if (type === 'parameter' && code.includes(`${varName}`) && /secret\s*:\s*true/i.test(code.substring(match.index, match.index + 200))) {
707-
r.isSecret = true;
709+
const cleaned = preprocessCode(code);
710+
const statements = splitStatements(cleaned);
711+
712+
for (const stmt of statements) {
713+
// Step 1: Extract builder.Add* resource declarations
714+
const addMatch = stmt.match(
715+
/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=\s*builder\.(\w+)\s*(?:<[^>]+>)?\s*\(\s*"([^"]+)"(?:\s*,\s*(?:@?"([^"]*)"))?/
716+
);
717+
if (addMatch) {
718+
const [, varName, method, name, secondArg] = addMatch;
719+
const type = METHOD_TO_TYPE[method];
720+
if (type) {
721+
const id = makeId();
722+
varToId.set(varName, id);
723+
varToStmt.set(varName, stmt);
724+
725+
const r = makeResource({ id, type, name });
726+
if (type === 'container' && secondArg) r.image = secondArg;
727+
if ((type === 'npmapp' || type === 'pythonapp' || type === 'project') && secondArg) r.projectPath = secondArg;
728+
if (type === 'parameter' && /secret\s*:\s*true/i.test(stmt)) r.isSecret = true;
729+
resources.push(r);
730+
}
708731
}
709-
resources.push(r);
710-
}
711732

712-
// Match: .AddDatabase("<name>")
713-
const dbRegex = /(\w+)\.AddDatabase\s*\("([^"]+)"\)/g;
714-
while ((match = dbRegex.exec(code)) !== null) {
715-
const [, parentVar, dbName] = match;
716-
const parentId = varToId.get(parentVar);
717-
if (parentId) {
718-
const parent = resources.find((r) => r.id === parentId);
719-
if (parent) parent.databases.push(dbName);
733+
// Step 2: Extract .AddDatabase() anywhere in the statement
734+
const dbMatches = stmt.matchAll(/\.AddDatabase\s*\(\s*"([^"]+)"\s*\)/g);
735+
for (const dbMatch of dbMatches) {
736+
const dbName = dbMatch[1];
737+
// The parent is the variable assigned in the same statement that contains
738+
// the builder.Add* call, OR the variable whose chain this belongs to.
739+
// First, try to find the builder.Add* parent in this statement.
740+
const parentAddMatch = stmt.match(
741+
/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=\s*builder\.(\w+)/
742+
);
743+
if (parentAddMatch) {
744+
// The database is chained onto the resource defined in this statement
745+
const parentVar = parentAddMatch[1];
746+
const parentId = varToId.get(parentVar);
747+
if (parentId) {
748+
const parent = resources.find(r => r.id === parentId);
749+
if (parent && !parent.databases.includes(dbName)) parent.databases.push(dbName);
750+
}
751+
} else {
752+
// The database call is on a standalone variable: varName.AddDatabase(...)
753+
const standaloneMatch = stmt.match(/(\w+)\s*\.\s*AddDatabase/);
754+
if (standaloneMatch) {
755+
const parentId = varToId.get(standaloneMatch[1]);
756+
if (parentId) {
757+
const parent = resources.find(r => r.id === parentId);
758+
if (parent && !parent.databases.includes(dbName)) parent.databases.push(dbName);
759+
}
760+
}
761+
}
720762
}
721-
}
722763

723-
// Match: .WithReference(<var>) and .WaitFor(<var>)
724-
const chainRegex = /(\w+)(?:\.[\w<>]+\([^)]*\))*\.(WithReference|WaitFor)\((\w+)\)/g;
725-
while ((match = chainRegex.exec(code)) !== null) {
726-
const [, consumerVar, method, depVar] = match;
727-
const consumerId = varToId.get(consumerVar);
728-
const depId = varToId.get(depVar);
729-
if (!consumerId || !depId) continue;
730-
const consumer = resources.find((r) => r.id === consumerId);
731-
if (!consumer) continue;
732-
if (method === 'WithReference' && !consumer.references.includes(depId)) {
733-
consumer.references.push(depId);
764+
// Step 3: Also handle `var dbVar = parentVar.AddDatabase("name")` as a variable assignment
765+
const dbAssignMatch = stmt.match(
766+
/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=\s*(\w+)(?:\.\w+\([^)]*\))*\.AddDatabase\s*\(\s*"([^"]+)"\s*\)/
767+
);
768+
if (dbAssignMatch) {
769+
const [, dbVarName, , dbName] = dbAssignMatch;
770+
// Find the database resource by name and map the variable to it
771+
// The database itself isn't a separate PlaygroundResource — it's stored on the parent.
772+
// But the variable might be referenced via .WithReference(dbVar), so we need to track it.
773+
// Find which parent has this database
774+
const parentForDb = resources.find(r => r.databases.includes(dbName));
775+
if (parentForDb) {
776+
varToId.set(dbVarName, parentForDb.id);
777+
varToStmt.set(dbVarName, stmt);
778+
}
734779
}
735-
if (method === 'WaitFor' && !consumer.waitFor.includes(depId)) {
736-
consumer.waitFor.push(depId);
780+
781+
// Step 4: Extract .WithReference() and .WaitFor() — find the consumer variable
782+
const refMatches = [...stmt.matchAll(/\.(WithReference|WaitFor)\s*\(\s*(\w+)\s*\)/g)];
783+
if (refMatches.length > 0) {
784+
// Find the consumer: the variable being assigned in this statement
785+
const consumerMatch = stmt.match(/(?:var|const|[A-Z]\w*)\s+(\w+)\s*=/);
786+
const consumerVar = consumerMatch?.[1];
787+
const consumerId = consumerVar ? varToId.get(consumerVar) : undefined;
788+
const consumer = consumerId ? resources.find(r => r.id === consumerId) : undefined;
789+
790+
if (!consumer) {
791+
// Try standalone: varName.WithReference(...)
792+
const standaloneConsumer = stmt.match(/^(\w+)\s*\./);
793+
if (standaloneConsumer) {
794+
const scId = varToId.get(standaloneConsumer[1]);
795+
const sc = scId ? resources.find(r => r.id === scId) : undefined;
796+
if (sc) {
797+
for (const rm of refMatches) {
798+
const [, method, depVar] = rm;
799+
const depId = varToId.get(depVar);
800+
if (!depId) continue;
801+
if (method === 'WithReference' && !sc.references.includes(depId)) sc.references.push(depId);
802+
if (method === 'WaitFor' && !sc.waitFor.includes(depId)) sc.waitFor.push(depId);
803+
}
804+
}
805+
}
806+
} else {
807+
for (const rm of refMatches) {
808+
const [, method, depVar] = rm;
809+
const depId = varToId.get(depVar);
810+
if (!depId) continue;
811+
if (method === 'WithReference' && !consumer.references.includes(depId)) consumer.references.push(depId);
812+
if (method === 'WaitFor' && !consumer.waitFor.includes(depId)) consumer.waitFor.push(depId);
813+
}
814+
}
737815
}
738816
}
739817

740-
// Match: .WithDataVolume()
818+
// Step 5: Scan statements for attribute methods (.WithDataVolume, .WithLifetime, etc.)
741819
for (const r of resources) {
742820
const varName = [...varToId.entries()].find(([, id]) => id === r.id)?.[0];
743-
if (varName && new RegExp(`${varName}[^;]*\\.WithDataVolume`).test(code)) r.hasDataVolume = true;
744-
if (varName && new RegExp(`${varName}[^;]*\\.WithLifetime\\(ContainerLifetime\\.Persistent\\)`).test(code)) r.isPersistent = true;
745-
if (varName && new RegExp(`${varName}[^;]*\\.WithExternalHttpEndpoints`).test(code)) r.hasExternalEndpoints = true;
746-
if (varName && new RegExp(`${varName}[^;]*\\.WithHttpEndpoint`).test(code)) r.hasExternalEndpoints = true;
821+
if (!varName) continue;
822+
const stmt = varToStmt.get(varName) ?? '';
823+
if (/\.WithDataVolume\b/.test(stmt)) r.hasDataVolume = true;
824+
if (/\.WithLifetime\s*\(\s*ContainerLifetime\.Persistent\s*\)/.test(stmt)) r.isPersistent = true;
825+
if (/\.WithExternalHttpEndpoints\b/.test(stmt)) r.hasExternalEndpoints = true;
826+
if (/\.WithHttpEndpoint\b/.test(stmt)) r.hasExternalEndpoints = true;
747827
}
748828

749829
return resources;
@@ -850,6 +930,7 @@ export default function PlaygroundPage() {
850930
const [activeTab, setActiveTab] = useState('canvas');
851931
const [importText, setImportText] = useState('');
852932
const [showImport, setShowImport] = useState(false);
933+
const [importFeedback, setImportFeedback] = useState('');
853934
const [highlightedResource, setHighlightedResource] = useState<string | null>(null);
854935

855936
// Undo/redo history — uses refs for index/history to avoid stale closures
@@ -1095,6 +1176,11 @@ export default function PlaygroundPage() {
10951176
setShowImport(false);
10961177
setImportText('');
10971178
setActiveTab('canvas');
1179+
setImportFeedback(`Imported ${parsed.length} resource${parsed.length === 1 ? '' : 's'}`);
1180+
setTimeout(() => setImportFeedback(''), 3000);
1181+
} else {
1182+
setImportFeedback('No resources found — check your AppHost code');
1183+
setTimeout(() => setImportFeedback(''), 3000);
10981184
}
10991185
}, [importText]);
11001186

@@ -1769,6 +1855,12 @@ export default function PlaygroundPage() {
17691855
</Box>
17701856
)}
17711857

1858+
{importFeedback && (
1859+
<Box mb="3" p="2" bg={importFeedback.startsWith('No') ? 'red.950' : 'green.950'} borderRadius="sm" border="1px solid" borderColor={importFeedback.startsWith('No') ? 'red.700' : 'green.700'}>
1860+
<Text fontSize="xs" color={importFeedback.startsWith('No') ? 'red.300' : 'green.300'}>{importFeedback}</Text>
1861+
</Box>
1862+
)}
1863+
17721864
{/* Name warnings */}
17731865
{nameWarnings.length > 0 && (
17741866
<Box mb="3" p="2" bg="red.950" borderRadius="sm" border="1px solid" borderColor="red.700">

0 commit comments

Comments
 (0)