diff --git a/packages/core/src/binder.ts b/packages/core/src/binder.ts index 315b88660..3545867f4 100644 --- a/packages/core/src/binder.ts +++ b/packages/core/src/binder.ts @@ -1,8 +1,9 @@ -import { memo } from "@alloy-js/core/jsx-runtime"; +import { memo, untrack } from "@alloy-js/core/jsx-runtime"; import { computed, effect, reactive, + ref, Ref, ShallowRef, shallowRef, @@ -302,6 +303,45 @@ export interface Binder { key: Refkey, ): Ref | undefined>; + /** + * Find a symbol with a given name in the given scope. Returns a ref + * for the symbol, such that when the symbol is available, the ref value + * will update. + */ + findSymbolName< + TScope extends OutputScope = OutputScope, + TSymbol extends OutputSymbol = OutputSymbol, + >( + currentScope: TScope | undefined, + name: string, + ): Ref; + + findScopeName( + currentScope: TScope | undefined, + name: string, + ): Ref; + + /** + * Resolve a fully qualified name to a symbol. The syntax for a fully + * qualified name is as follows: + * + * * `::` accesses a nested scope + * * `.` accesses a nested static member + * * `#` accesses a nested instance member + * + * Per-language packages may provide their own resolveFQN function + * that uses syntax more natural to that language. + */ + resolveFQN< + TScope extends OutputScope = OutputScope, + TSymbol extends OutputSymbol = OutputSymbol, + >( + fqn: string, + ): Ref; + + /** + * The global scope. This is the root scope for all symbols. + */ globalScope: OutputScope; } @@ -377,6 +417,9 @@ export function createOutputBinder(options: BinderOptions = {}): Binder { addStaticMembersToSymbol, addInstanceMembersToSymbol, instantiateSymbolInto, + findSymbolName, + findScopeName, + resolveFQN: resolveFQN as any, globalScope: undefined as any, }; @@ -396,6 +439,14 @@ export function createOutputBinder(options: BinderOptions = {}): Binder { const knownDeclarations = new Map(); const waitingDeclarations = new Map>(); + const waitingSymbolNames = new Map< + OutputScope, + Map> + >(); + const waitingScopeNames = new Map< + OutputScope, + Map> + >(); return binder; @@ -454,6 +505,15 @@ export function createOutputBinder(options: BinderOptions = {}): Binder { parentScope.children.add(scope); } + if (waitingScopeNames.has(parentScope!)) { + const waiting = waitingScopeNames.get(parentScope!); + const targetName = name.replace(/\./g, "_"); + if (waiting?.has(targetName)) { + const ref = waiting.get(targetName)!; + ref.value = scope; + } + } + return scope as T; } @@ -757,6 +817,130 @@ export function createOutputBinder(options: BinderOptions = {}): Binder { const signal = waitingDeclarations.get(refkey)!; signal.value = symbol; } + + const waitingScope = waitingSymbolNames.get(symbol.scope); + if (waitingScope) { + const waitingName = waitingScope.get(symbol.name); + if (waitingName) { + waitingName.value = symbol; + } + } + } + + function findSymbolName( + scope: OutputScope | undefined, + name: string, + ): Ref { + return untrack(() => { + scope ??= binder.globalScope; + for (const sym of scope.symbols) { + if (sym.name === name) { + return shallowRef(sym) as Ref; + } + } + + if (!waitingSymbolNames.has(scope)) { + waitingSymbolNames.set(scope, new Map()); + } + const waiting = waitingSymbolNames.get(scope)!; + if (waiting.has(name)) { + return waiting.get(name) as Ref; + } + const symRef = shallowRef(undefined); + waiting.set(name, symRef); + return symRef as Ref; + }); + } + + function findScopeName( + scope: OutputScope | undefined, + name: string, + ): Ref { + return untrack(() => { + scope ??= binder.globalScope; + + for (const child of scope.children) { + if (child.name.replace(/\./g, "_") === name) { + return ref(child) as Ref; + } + } + + if (!waitingScopeNames.has(scope)) { + waitingScopeNames.set(scope, new Map()); + } + const waiting = waitingScopeNames.get(scope)!; + const key = name.replace(/\./g, "_"); + if (waiting.has(key)) { + return waiting.get(key) as Ref; + } + + const scopeRef = shallowRef(undefined); + waiting.set(name.replace(/\./g, "_"), scopeRef); + + return scopeRef as Ref; + }); + } + + function findScopeOrSymbolName(scope: OutputScope, name: string) { + return untrack(() => { + return computed(() => { + return ( + findSymbolName(scope, name).value ?? findScopeName(scope, name).value + ); + }); + }); + } + + function resolveFQN( + fqn: string, + ): Ref { + const parts = fqn.match(/[^.#]+|[.#]/g); + if (!parts) return ref(undefined); + if (parts.length === 0) return ref(undefined); + + parts.unshift("."); + + return computed(() => { + let base: OutputScope | OutputSymbol | undefined = binder.globalScope; + + for (let i = 0; i < parts.length; i += 2) { + if (base === undefined) { + return; + } + + const op = parts[i]; + const name = parts[i + 1]; + + if (op === ".") { + if ("originalName" in base) { + if (!base.staticMemberScope) { + return undefined; + } + + base = findSymbolName( + (base as OutputSymbol).staticMemberScope, + name, + ).value; + } else { + base = findScopeOrSymbolName(base, name).value; + } + } else if (op === "#") { + if ("originalName" in base) { + if (!base.instanceMemberScope) { + return undefined; + } + base = findSymbolName( + (base as OutputSymbol).instanceMemberScope, + name, + ).value; + } else { + return undefined; + } + } + } + + return base; + }); } } diff --git a/packages/core/src/components/Output.tsx b/packages/core/src/components/Output.tsx index 1fa5ec2c0..c32f42315 100644 --- a/packages/core/src/components/Output.tsx +++ b/packages/core/src/components/Output.tsx @@ -9,6 +9,7 @@ import { NamePolicyContext } from "../context/name-policy.js"; import { NamePolicy } from "../name-policy.js"; import { SourceDirectory } from "./SourceDirectory.js"; // eslint-disable-next-line @typescript-eslint/no-unused-vars +import { extensionEffects } from "../slot.js"; import { SourceFile } from "./SourceFile.js"; export interface OutputProps { @@ -58,7 +59,7 @@ export function Output(props: OutputProps) { } return - { + {() => { extensionEffects.forEach(e => e())}}{ props.namePolicy ? {dir} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fe1901195..b823b879c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -18,4 +18,5 @@ export * from "./jsx-runtime.js"; export * from "./name-policy.js"; export * from "./refkey.js"; export * from "./render.js"; +export * from "./slot.js"; export * from "./utils.js"; diff --git a/packages/core/src/slot.ts b/packages/core/src/slot.ts new file mode 100644 index 000000000..fd8c6dfe2 --- /dev/null +++ b/packages/core/src/slot.ts @@ -0,0 +1,111 @@ +import { isRef, Ref } from "@vue/reactivity"; +import { OutputSymbol } from "./binder.js"; +import { useBinder } from "./context/binder.js"; +import { + Children, + Component, + ComponentDefinition, + effect, + memo, +} from "./jsx-runtime.js"; + +export interface SlotInstance extends ComponentDefinition {} + +export interface SlotDefinition { + find: (...args: any[]) => () => Ref; + create( + key: unknown, + props: TSlotProps, + defaultContent?: Children, + ): ComponentDefinition; +} + +export type SlotKey = Ref; + +const slotMappers = new Map>(); + +export function defineSlot< + TSlotProps, + TFinder extends (...args: any[]) => Ref | unknown = ( + ...args: any[] + ) => Ref | unknown, +>(finder: TFinder): SlotDefinition { + return { + find: ((...args: any[]) => + () => + finder(...args)) as any, + create(key, props, defaultContent) { + return function () { + return memo(() => { + if (key === undefined) { + return defaultContent; + } + + const component = slotMappers.get(key); + + if (!component) { + return defaultContent; + } + + return component({ ...props, original: defaultContent }); + }); + }; + }, + }; +} + +export const extensionEffects: (() => void)[] = []; + +export function replace( + slotKeyFn: () => SlotKey, + replacement: Component, +) { + extensionEffects.push(() => { + effect((prev: SlotKey | undefined) => { + if (prev) { + slotMappers.delete(prev.value); + } + + let slotKey = slotKeyFn(); + if (typeof slotKey === "function") { + slotKey = (slotKey as any)(); + } + + if (isRef(slotKey)) { + if (slotKey.value === undefined) { + return slotKey; + } + + slotMappers.set(slotKey.value, replacement); + } else { + if (slotKey === undefined) { + return slotKey; + } + + slotMappers.set(slotKey, replacement); + } + + return slotKey; + }); + }); +} + +export function rename( + slotKeyFn: () => Ref, + newName: string, +) { + extensionEffects.push(() => { + effect(() => { + const sym = slotKeyFn().value; + if (!sym) return; + sym.name = newName; + }); + }); +} + +export function resolveFQN(fqn: string) { + return () => { + const binder = useBinder(); + return binder.resolveFQN(fqn) as Ref; + }; +} diff --git a/packages/core/test/components/slot.test.tsx b/packages/core/test/components/slot.test.tsx new file mode 100644 index 000000000..58c15493b --- /dev/null +++ b/packages/core/test/components/slot.test.tsx @@ -0,0 +1,264 @@ +import { it } from "vitest"; +import { Output } from "../../src/components/Output.jsx"; +import { SourceFile } from "../../src/components/SourceFile.jsx"; +import { + Declaration, + Name, + refkey, + Scope, + useBinder, +} from "../../src/index.js"; +import { render } from "../../src/render.js"; +import { defineSlot, rename, replace, resolveFQN } from "../../src/slot.js"; +import "../../testing/extend-expect.js"; + +it("works with string keys", () => { + interface FunctionSlotProps extends FunctionComponentProps { + additionalProp: string; + } + + const FunctionSlot = defineSlot( + (query: { name: string }) => query.name, + ); + + interface FunctionComponentProps { + name: string; + } + + function MyFunctionComponent(props: FunctionComponentProps) { + const FunctionSlotInstance = FunctionSlot.create( + props.name, + { ...props, additionalProp: "hi" }, + <> + function {props.name}() {"{"} + console.log("hello world"); + {"}"} + , + ); + + return ; + } + + // extension.tsx + replace(FunctionSlot.find({ name: "foo" }), (props: any) => { + return <> + // original + { props.original } + ; + }); + + const tree = render( + + + + + , + ); + + console.log(tree.contents[0].contents); +}); + +it("works with symbols", () => { + interface FunctionSlotProps extends FunctionComponentProps { + additionalProp: string; + } + + const FunctionSlot = defineSlot((query: { fqn: string }) => + resolveFQN(query.fqn)); + + interface FunctionComponentProps { + name: string; + } + + function MyFunctionComponent(props: FunctionComponentProps) { + const binder = useBinder(); + const sym = binder.createSymbol({ + name: props.name, + refkey: refkey(), + }); + + const FunctionSlotInstance = FunctionSlot.create( + sym, + { ...props, additionalProp: "hi" }, + + function () {"{"} + console.log("hello world"); + {"}"} + , + ); + + return ; + } + + // extension.tsx + replace(FunctionSlot.find({ fqn: "foo.bar" }), (props: any) => { + return <> + // original + { props.original } + ; + }); + + const tree = render( + + + + + + + , + ); + + console.log(tree.contents[0].contents); +}); + +it("can rename", () => { + interface FunctionSlotProps extends FunctionComponentProps { + additionalProp: string; + } + + const FunctionSlot = defineSlot((query: { fqn: string }) => + resolveFQN(query.fqn)); + + interface FunctionComponentProps { + name: string; + } + + function MyFunctionComponent(props: FunctionComponentProps) { + const binder = useBinder(); + const sym = binder.createSymbol({ + name: props.name, + refkey: refkey(), + }); + + const FunctionSlotInstance = FunctionSlot.create( + sym, + { ...props, additionalProp: "hi" }, + + function () {"{"} + console.log("hello world"); + {"}"} + , + ); + + return ; + } + + rename(resolveFQN("foo.bar"), "bazxxx"); + + const tree = render( + + + + + + + , + ); + + console.log(tree.contents[0].contents); +}); + +it("can rename the same thing, last wins", () => { + interface FunctionSlotProps extends FunctionComponentProps { + additionalProp: string; + } + + const FunctionSlot = defineSlot((query: { fqn: string }) => + resolveFQN(query.fqn)); + + interface FunctionComponentProps { + name: string; + } + + function MyFunctionComponent(props: FunctionComponentProps) { + const binder = useBinder(); + const sym = binder.createSymbol({ + name: props.name, + refkey: refkey(), + }); + + const FunctionSlotInstance = FunctionSlot.create( + sym, + { ...props, additionalProp: "hi" }, + + function () {"{"} + console.log("hello world"); + {"}"} + , + ); + + return ; + } + + rename(resolveFQN("foo.bar"), "bazxxx"); + rename(resolveFQN("foo.bar"), "bazyyy"); + rename(resolveFQN("foo.bar"), "bazzzz"); + + const tree = render( + + + + + + + , + ); + + console.log(tree.contents[0].contents); +}); + +it("works with symbols with rename", () => { + interface FunctionSlotProps extends FunctionComponentProps { + additionalProp: string; + } + + const FunctionSlot = defineSlot((query: { fqn: string }) => + resolveFQN(query.fqn)); + + interface FunctionComponentProps { + name: string; + } + + function MyFunctionComponent(props: FunctionComponentProps) { + const binder = useBinder(); + const sym = binder.createSymbol({ + name: props.name, + refkey: refkey(), + }); + + const FunctionSlotInstance = FunctionSlot.create( + sym, + { ...props, additionalProp: "hi" }, + + function () {"{"} + console.log("hello world"); + {"}"} + , + ); + + return ; + } + + const fqn = resolveFQN("foo.bar"); + + rename(fqn, "baz"); + // extension.tsx + replace(FunctionSlot.find({ fqn: "foo.bar" }), (props: any) => { + return <> + // original + { props.original } + ; + }); + + const tree = render( + + + + + + + , + ); + + console.log(tree.contents[0].contents); +}); diff --git a/packages/core/test/symbols.test.ts b/packages/core/test/symbols.test.ts index 9657b370a..308bc1911 100644 --- a/packages/core/test/symbols.test.ts +++ b/packages/core/test/symbols.test.ts @@ -404,3 +404,105 @@ describe("instantiating members", () => { ).toBeDefined(); }); }); + +describe("symbol name resolution", () => { + it("resolves static symbols", () => { + const binder = createOutputBinder(); + const { + symbols: { static: staticSym }, + } = createScopeTree(binder, { + root: { + symbols: { + root: { + flags: + OutputSymbolFlags.InstanceMemberContainer | + OutputSymbolFlags.StaticMemberContainer, + staticMembers: { + static: { + flags: OutputSymbolFlags.StaticMember, + }, + }, + }, + }, + }, + }); + + const result = binder.resolveFQN("root.root.static"); + expect(result.value).toEqual(staticSym); + }); + + it("resolves static symbols that are added later", () => { + const binder = createOutputBinder(); + const result = binder.resolveFQN("root.root.static"); + expect(result.value).toBeUndefined(); + + const { + symbols: { static: staticSym }, + } = createScopeTree(binder, { + root: { + symbols: { + root: { + flags: + OutputSymbolFlags.InstanceMemberContainer | + OutputSymbolFlags.StaticMemberContainer, + staticMembers: { + static: { + flags: OutputSymbolFlags.StaticMember, + }, + }, + }, + }, + }, + }); + + expect(result.value).toEqual(staticSym); + }); + + it("resolves instance symbols", () => { + const binder = createOutputBinder(); + const { + symbols: { instance }, + } = createScopeTree(binder, { + root: { + symbols: { + root: { + flags: OutputSymbolFlags.InstanceMemberContainer, + instanceMembers: { + instance: { + flags: OutputSymbolFlags.InstanceMember, + }, + }, + }, + }, + }, + }); + + const result = binder.resolveFQN("root.root#instance"); + expect(result.value).toEqual(instance); + }); + + it("resolves instance symbols that are added later", () => { + const binder = createOutputBinder(); + const result = binder.resolveFQN("root.root#instance"); + expect(result.value).toBeUndefined(); + + const { + symbols: { instance }, + } = createScopeTree(binder, { + root: { + symbols: { + root: { + flags: OutputSymbolFlags.InstanceMemberContainer, + instanceMembers: { + instance: { + flags: OutputSymbolFlags.InstanceMember, + }, + }, + }, + }, + }, + }); + + expect(result.value).toEqual(instance); + }); +}); diff --git a/packages/java/src/index.ts b/packages/java/src/index.ts index 632a35214..73e073eb3 100644 --- a/packages/java/src/index.ts +++ b/packages/java/src/index.ts @@ -3,6 +3,7 @@ export * from "./builtins/index.js"; export * from "./components/index.js"; export * from "./create-library.js"; export * from "./generics.js"; +export * from "./javaslots.js"; export * from "./name-policy.js"; export * from "./object-modifiers.js"; export * from "./symbols/index.js"; diff --git a/packages/java/src/javaslots.ts b/packages/java/src/javaslots.ts new file mode 100644 index 000000000..00d5b8cbd --- /dev/null +++ b/packages/java/src/javaslots.ts @@ -0,0 +1,35 @@ +import { resolveFQN } from "@alloy-js/core"; + +export function resolveJavaFQN( + artifactId: string, + javaFileName: string, + pkg?: string, + memberName?: string, + isStatic: boolean = false, +) { + const fqn = + artifactId + + "." + + transformJavaFqn(pkg) + + "." + + javaFileName.replace(".", "_") + + (memberName ? + isStatic ? "_" + memberName + : "." + memberName + : ""); + console.log("Resolved Java FQN", fqn); + return resolveFQN(fqn); +} + +function transformJavaFqn(input?: string): string { + if (!input) return ""; + const parts = input.split("."); + return parts + .map((part, index) => { + if (index < parts.length) { + return parts.slice(0, index + 1).join("_"); + } + return part; + }) + .join("."); +} diff --git a/packages/typescript/src/components/Declaration.tsx b/packages/typescript/src/components/Declaration.tsx index 2c4278d5f..f9d3e8bae 100644 --- a/packages/typescript/src/components/Declaration.tsx +++ b/packages/typescript/src/components/Declaration.tsx @@ -7,7 +7,11 @@ import { Refkey, } from "@alloy-js/core"; import { TypeScriptElements, useTSNamePolicy } from "../name-policy.js"; -import { createTSSymbol, TSSymbolFlags } from "../symbols/index.js"; +import { + createTSSymbol, + TSOutputSymbol, + TSSymbolFlags, +} from "../symbols/index.js"; // imports for documentation // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -44,6 +48,11 @@ export interface BaseDeclarationProps { */ flags?: OutputSymbolFlags; + /** + * The symbol to use for this declaration. + */ + symbol?: TSOutputSymbol; + children?: Children; /** @@ -77,19 +86,24 @@ export interface DeclarationProps extends BaseDeclarationProps { export function Declaration(props: DeclarationProps) { const namePolicy = useTSNamePolicy(); - let tsFlags: TSSymbolFlags = TSSymbolFlags.None; - if (props.kind && props.kind === "type") { - tsFlags &= TSSymbolFlags.TypeSymbol; - } + let sym: TSOutputSymbol; + if (props.symbol) { + sym = props.symbol; + } else { + let tsFlags: TSSymbolFlags = TSSymbolFlags.None; + if (props.kind && props.kind === "type") { + tsFlags &= TSSymbolFlags.TypeSymbol; + } - const sym = createTSSymbol({ - name: namePolicy.getName(props.name, props.nameKind), - refkey: props.refkey ?? refkey(props.name), - export: props.export, - default: props.default, - flags: props.flags, - tsFlags, - }); + sym = createTSSymbol({ + name: namePolicy.getName(props.name, props.nameKind), + refkey: props.refkey ?? refkey(props.name), + export: props.export, + default: props.default, + flags: props.flags, + tsFlags, + }); + } let children: Children;