diff --git a/app/gui2/e2e/collapsingAndEntering.spec.ts b/app/gui2/e2e/collapsingAndEntering.spec.ts index 6c1d04a604..ca92ed4eee 100644 --- a/app/gui2/e2e/collapsingAndEntering.spec.ts +++ b/app/gui2/e2e/collapsingAndEntering.spec.ts @@ -5,7 +5,7 @@ import { mockCollapsedFunctionInfo } from './expressionUpdates' import { CONTROL_KEY } from './keyboard' import * as locate from './locate' -const MAIN_FILE_NODES = 11 +const MAIN_FILE_NODES = 12 const COLLAPSE_SHORTCUT = `${CONTROL_KEY}+G` @@ -120,6 +120,7 @@ async function expectInsideMain(page: Page) { await expect(locate.graphNodeByBinding(page, 'data')).toExist() await expect(locate.graphNodeByBinding(page, 'aggregated')).toExist() await expect(locate.graphNodeByBinding(page, 'filtered')).toExist() + await expect(locate.graphNodeByBinding(page, 'autoscoped')).toExist() } async function expectInsideFunc1(page: Page) { diff --git a/app/gui2/e2e/componentBrowser.spec.ts b/app/gui2/e2e/componentBrowser.spec.ts index 5fef700e1c..c850918255 100644 --- a/app/gui2/e2e/componentBrowser.spec.ts +++ b/app/gui2/e2e/componentBrowser.spec.ts @@ -101,7 +101,7 @@ test('Graph Editor pans to Component Browser', async ({ page }) => { await expect(locate.graphNodeByBinding(page, 'five')).toBeInViewport() const outputPort = await locate.outputPortCoordinates(locate.graphNodeByBinding(page, 'final')) await page.mouse.click(outputPort.x, outputPort.y) - await page.mouse.click(100, 1550) + await page.mouse.click(100, 1700) await expect(locate.graphNodeByBinding(page, 'five')).not.toBeInViewport() await expectAndCancelBrowser(page, 'final.') }) diff --git a/app/gui2/e2e/edgeRendering.spec.ts b/app/gui2/e2e/edgeRendering.spec.ts index cb0d795ecc..761f4a775f 100644 --- a/app/gui2/e2e/edgeRendering.spec.ts +++ b/app/gui2/e2e/edgeRendering.spec.ts @@ -12,7 +12,7 @@ test('Existence of edges between nodes', async ({ page }) => { await expect(await edgesFromNodeWithBinding(page, 'aggregated')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'filtered')).toHaveCount(0) - await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(3 * EDGE_PARTS) + await expect(await edgesFromNodeWithBinding(page, 'data')).toHaveCount(4 * EDGE_PARTS) await expect(await edgesFromNodeWithBinding(page, 'list')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'final')).toHaveCount(0) await expect(await edgesFromNodeWithBinding(page, 'selected')).toHaveCount(0) diff --git a/app/gui2/e2e/widgets.spec.ts b/app/gui2/e2e/widgets.spec.ts index f23dbd7170..2970f6c200 100644 --- a/app/gui2/e2e/widgets.spec.ts +++ b/app/gui2/e2e/widgets.spec.ts @@ -440,3 +440,39 @@ test('Managing aggregates in `aggregate` node', async ({ page }) => { // '"', // ]) }) + +// Test that autoscoped constructors provide argument placeholders. +// This test can be removed when `aggregate` inserts autoscoped constructors by default, +// so this behavior will be tested in regular `aggregate` tests. +test('Autoscoped constructors', async ({ page }) => { + await actions.goToGraph(page) + await mockMethodCallInfo(page, 'autoscoped', { + methodPointer: { + module: 'Standard.Table.Table', + definedOnType: 'Standard.Table.Table.Table', + name: 'aggregate', + }, + notAppliedArguments: [2, 3], + }) + await mockMethodCallInfo( + page, + { binding: 'autoscoped', expr: '..Group_By' }, + { + methodPointer: { + module: 'Standard.Table.Aggregate_Column', + definedOnType: 'Standard.Table.Aggregate_Column.Aggregate_Column', + name: 'Group_By', + }, + notAppliedArguments: [0, 1], + }, + ) + const node = locate.graphNodeByBinding(page, 'autoscoped') + const topLevelArgs = node.locator('.WidgetTopLevelArgument') + // Wait for hidden arguments to appear after selecting the node. + await node.click() + await expect(topLevelArgs).toHaveCount(3) + + const groupBy = node.locator('.item').nth(0) + await expect(groupBy).toBeVisible() + await expect(groupBy.locator('.WidgetArgumentName')).toContainText(['column', 'new_name']) +}) diff --git a/app/gui2/eslint.config.js b/app/gui2/eslint.config.js index 7953ac7880..f52cbc549b 100644 --- a/app/gui2/eslint.config.js +++ b/app/gui2/eslint.config.js @@ -17,6 +17,7 @@ const conf = [ 'templates', '.histoire', 'playwright-report', + 'test-results', ], }, ...compat.extends('plugin:vue/vue3-recommended'), diff --git a/app/gui2/mock/engine.ts b/app/gui2/mock/engine.ts index e524523464..408115d551 100644 --- a/app/gui2/mock/engine.ts +++ b/app/gui2/mock/engine.ts @@ -70,6 +70,7 @@ main = data = Data.read filtered = data.filter aggregated = data.aggregate + autoscoped = data.aggregate [..Group_By] selected = data.select_columns ` diff --git a/app/gui2/playwright.config.ts b/app/gui2/playwright.config.ts index f6924330d9..04a5a1b6e6 100644 --- a/app/gui2/playwright.config.ts +++ b/app/gui2/playwright.config.ts @@ -48,7 +48,7 @@ export default defineConfig({ use: { headless: !DEBUG, trace: 'retain-on-failure', - viewport: { width: 1920, height: 1600 }, + viewport: { width: 1920, height: 1750 }, ...(DEBUG ? {} : { diff --git a/app/gui2/shared/ast/parse.ts b/app/gui2/shared/ast/parse.ts index ae62d524d2..aa04842dcb 100644 --- a/app/gui2/shared/ast/parse.ts +++ b/app/gui2/shared/ast/parse.ts @@ -39,6 +39,7 @@ import { App, Assignment, Ast, + AutoscopedIdentifier, BodyBlock, Documented, Function, @@ -203,6 +204,12 @@ class Abstractor { } break } + case RawAst.Tree.Type.AutoscopedIdentifier: { + const opr = this.abstractToken(tree.opr) + const ident = this.abstractToken(tree.ident) + node = AutoscopedIdentifier.concrete(this.module, opr, ident) + break + } case RawAst.Tree.Type.OprApp: { const lhs = tree.lhs ? this.abstractTree(tree.lhs) : undefined const opr = diff --git a/app/gui2/shared/ast/token.ts b/app/gui2/shared/ast/token.ts index 7fae63a44c..ccc1117175 100644 --- a/app/gui2/shared/ast/token.ts +++ b/app/gui2/shared/ast/token.ts @@ -77,6 +77,7 @@ export interface IdentifierToken extends Token { declare const qualifiedNameBrand: unique symbol declare const identifierBrand: unique symbol +declare const typeOrConsIdentifierBrand: unique symbol declare const operatorBrand: unique symbol /** A string representing a valid qualified name of our language. @@ -89,6 +90,9 @@ export type QualifiedName = string & { [qualifiedNameBrand]: never } /** A string representing a lexical identifier. */ export type Identifier = string & { [identifierBrand]: never; [qualifiedNameBrand]: never } +/** A specific subtype of capitalized identifier, used for type and constructor names. */ +export type TypeOrConstructorIdentifier = Identifier & { [typeOrConsIdentifierBrand]: never } + /** A string representing a lexical operator. */ export type Operator = string & { [operatorBrand]: never; [qualifiedNameBrand]: never } @@ -121,6 +125,11 @@ export function isIdentifier(code: string): code is Identifier { return is_ident_or_operator(code) === 1 } +export function isTypeOrConsIdentifier(code: string): code is TypeOrConstructorIdentifier { + const isUppercase = (s: string) => s.toUpperCase() === s && s.toLowerCase() !== s + return isIdentifier(code) && code.length > 0 && isUppercase(code[0]!) +} + export function identifier(code: string): Identifier | undefined { if (isIdentifier(code)) return code } diff --git a/app/gui2/shared/ast/tree.ts b/app/gui2/shared/ast/tree.ts index be7207fce4..e2953e96cd 100644 --- a/app/gui2/shared/ast/tree.ts +++ b/app/gui2/shared/ast/tree.ts @@ -10,6 +10,7 @@ import type { RawNodeChild, SpanMap, SyncTokenId, + TypeOrConstructorIdentifier, } from '.' import { MutableModule, @@ -763,6 +764,61 @@ export interface MutableUnaryOprApp extends UnaryOprApp, MutableAst { } applyMixins(MutableUnaryOprApp, [MutableAst]) +interface AutoscopedIdentifierFields { + operator: NodeChild + identifier: NodeChild +} +export class AutoscopedIdentifier extends Ast { + declare fields: FixedMapView + constructor(module: Module, fields: FixedMapView) { + super(module, fields) + } + + static tryParse( + source: string, + module?: MutableModule, + ): Owned | undefined { + const parsed = parse(source, module) + if (parsed instanceof MutableAutoscopedIdentifier) return parsed + } + + static concrete(module: MutableModule, operator: NodeChild, identifier: NodeChild) { + const base = module.baseObject('AutoscopedIdentifier') + const fields = composeFieldData(base, { + operator, + identifier, + }) + return asOwned(new MutableAutoscopedIdentifier(module, fields)) + } + + static new( + identifier: TypeOrConstructorIdentifier, + module?: MutableModule, + ): Owned { + const module_ = module || MutableModule.Transient() + const operator = Token.new('..') + const ident = Token.new(identifier, RawAst.Token.Type.Ident) + return this.concrete(module_, unspaced(operator), unspaced(ident)) + } + + *concreteChildren(_verbatim?: boolean): IterableIterator { + const { operator, identifier } = getAll(this.fields) + yield operator + yield identifier + } +} +export class MutableAutoscopedIdentifier extends AutoscopedIdentifier implements MutableAst { + declare readonly module: MutableModule + declare readonly fields: FixedMap + + setIdentifier(value: TypeOrConstructorIdentifier) { + const token = Token.new(value, RawAst.Token.Type.Ident) + this.fields.set('identifier', unspaced(token)) + } +} +export interface MutableAutoscopedIdentifier extends AutoscopedIdentifier, MutableAst {} +applyMixins(MutableAutoscopedIdentifier, [MutableAst]) + interface NegationAppFields { operator: NodeChild argument: NodeChild @@ -2369,6 +2425,8 @@ export function materializeMutable(module: MutableModule, fields: FixedMap): As return new TextLiteral(module, fields_) case 'UnaryOprApp': return new UnaryOprApp(module, fields_) + case 'AutoscopedIdentifier': + return new AutoscopedIdentifier(module, fields_) case 'Vector': return new Vector(module, fields_) case 'Wildcard': diff --git a/app/gui2/src/providers/widgetRegistry.ts b/app/gui2/src/providers/widgetRegistry.ts index d7b475904e..33e57c38f5 100644 --- a/app/gui2/src/providers/widgetRegistry.ts +++ b/app/gui2/src/providers/widgetRegistry.ts @@ -65,7 +65,8 @@ export namespace WidgetInput { input.value instanceof Ast.App || input.value instanceof Ast.Ident || input.value instanceof Ast.PropertyAccess || - input.value instanceof Ast.OprApp + input.value instanceof Ast.OprApp || + input.value instanceof Ast.AutoscopedIdentifier ) } } diff --git a/app/gui2/src/stores/graph/index.ts b/app/gui2/src/stores/graph/index.ts index 026b9f22a7..ba0aa701ba 100644 --- a/app/gui2/src/stores/graph/index.ts +++ b/app/gui2/src/stores/graph/index.ts @@ -636,7 +636,11 @@ export const useGraphStore = defineStore('graph', () => { exprId = nodeId } - if (exprId == null) bail(`Cannot find expression located by ${locator}`) + if (exprId == null) { + const locatorStr = + typeof locator === 'string' ? locator : `${locator.binding}/${locator.expr}` + bail(`Cannot find expression located by ${locatorStr}`) + } const update_: ExpressionUpdate = { expressionId: db.idToExternal(exprId)!,