From 73eb83ab85f6802504b554423bde7f6f436ba4d4 Mon Sep 17 00:00:00 2001 From: Dylan Piercey Date: Wed, 8 Jul 2026 23:14:07 +0000 Subject: [PATCH] feat: support the core tag The from= attribute gets tag-name ("") and relative path completions plus document links, and a consumer's tag variable is typed as exactly the provided value of the template from= resolves to (a provide threads its value's type out through the template's return type; conditional and nested provide sites still type exactly, and writable assignments check against the provided type). --- .changeset/context-tag-support.md | 9 ++ .../__tests__/context-from-complete.test.ts | 104 +++++++++++++++ .../components/labeled-region.html | 6 + .../components/labeled-region.md | 12 ++ .../components/labeled-region.ts | 81 ++++++++++++ .../components/mode-provider.html | 7 + .../components/mode-provider.md | 0 .../components/mode-provider.ts | 74 +++++++++++ .../components/show-mode.html | 1 + .../components/show-mode.md | 13 ++ .../components/show-mode.ts | 69 ++++++++++ .../components/show-theme-relative.html | 1 + .../components/show-theme-relative.md | 10 ++ .../components/show-theme-relative.ts | 75 +++++++++++ .../components/show-theme.html | 1 + .../components/show-theme.md | 10 ++ .../components/show-theme.ts | 69 ++++++++++ .../components/theme-provider.html | 3 + .../components/theme-provider.md | 0 .../components/theme-provider.ts | 73 ++++++++++ .../context-tag.expected/index.html | 8 ++ .../context-tag.expected/index.md | 12 ++ .../context-tag.expected/index.ts | 103 +++++++++++++++ .../components/labeled-region.marko | 12 ++ .../components/mode-provider.marko | 16 +++ .../context-tag/components/show-mode.marko | 3 + .../components/show-theme-relative.marko | 3 + .../context-tag/components/show-theme.marko | 3 + .../components/theme-provider.marko | 9 ++ .../fixtures/script/context-tag/index.marko | 10 ++ .../src/service/marko/complete/AttrValue.ts | 35 ++++- .../src/service/marko/complete/Import.ts | 46 ++----- .../src/service/marko/document-links.ts | 21 ++- .../service/marko/util/context-from-attr.ts | 16 +++ .../marko/util/tag-shorthand-completions.ts | 39 ++++++ packages/language-tools/marko.internal.d.ts | 19 +++ .../src/extractors/script/index.ts | 125 +++++++++++++++++- 37 files changed, 1053 insertions(+), 45 deletions(-) create mode 100644 .changeset/context-tag-support.md create mode 100644 packages/language-server/src/__tests__/context-from-complete.test.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.html create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.md create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.html create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.md create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.html create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.md create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.html create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.md create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.html create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.md create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.html create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.md create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.html create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.md create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.ts create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/components/labeled-region.marko create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/components/mode-provider.marko create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-mode.marko create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme-relative.marko create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme.marko create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/components/theme-provider.marko create mode 100644 packages/language-server/src/__tests__/fixtures/script/context-tag/index.marko create mode 100644 packages/language-server/src/service/marko/util/context-from-attr.ts create mode 100644 packages/language-server/src/service/marko/util/tag-shorthand-completions.ts diff --git a/.changeset/context-tag-support.md b/.changeset/context-tag-support.md new file mode 100644 index 00000000..cfa39cf0 --- /dev/null +++ b/.changeset/context-tag-support.md @@ -0,0 +1,9 @@ +--- +"@marko/language-tools": minor +"@marko/language-server": minor +--- + +Support the `` core tag: the `from=` attribute gets tag-name +(`""`) and relative path completions plus document links, and a +consumer's tag variable is typed from the provided value of the template +`from=` resolves to (including writable assignments). diff --git a/packages/language-server/src/__tests__/context-from-complete.test.ts b/packages/language-server/src/__tests__/context-from-complete.test.ts new file mode 100644 index 00000000..d410c4c2 --- /dev/null +++ b/packages/language-server/src/__tests__/context-from-complete.test.ts @@ -0,0 +1,104 @@ +import assert from "node:assert/strict"; + +import { Project } from "@marko/language-tools"; +import path from "path"; +import { CancellationToken, type TextEdit } from "vscode-languageserver"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { URI } from "vscode-uri"; + +import { documents } from "../service"; +import MarkoPlugin from "../service/marko"; + +Project.setDefaultTypePaths({ + internalTypesFile: + require.resolve("@marko/language-tools/marko.internal.d.ts"), + markoTypesFile: require.resolve("marko/index.d.ts"), +}); + +// Reuses a fixture that ships discoverable `components/TestTagA.marko` / +// `TestTagB.marko`; the consumer is an in-memory file in that directory. +const FIXTURE_DIR = path.join( + __dirname, + "fixtures", + "script", + "prefer-local-identifier-tag-name", +); + +// Completes at `cursor` characters past the opening quote of the `from=` +// attribute and returns the item with `label` plus the applied line. +let runId = 0; +async function completeFrom(line: string, cursor: number, label: string) { + const uri = URI.file( + path.join(FIXTURE_DIR, `__context-from-${runId++}.marko`), + ).toString(); + const text = `${line}\n`; + documents.doOpen({ + textDocument: { uri, languageId: "marko", version: 1, text }, + }); + const doc = documents.get(uri)!; + try { + const offset = line.indexOf('from="') + 6 + cursor; + const result = await MarkoPlugin.doComplete!( + doc, + { + textDocument: { uri }, + position: doc.positionAt(offset), + context: { triggerKind: 1 }, + } as never, + CancellationToken.None, + ); + const items = Array.isArray(result) ? result : (result?.items ?? []); + const item = items.find((i) => i.label === label); + return { + item, + applied: item + ? TextDocument.applyEdits(doc, [item.textEdit as TextEdit]).split( + "\n", + )[0] + : undefined, + }; + } finally { + documents.doClose({ textDocument: { uri } }); + } +} + +describe(" from= completion", () => { + it("completes a tag name inside the shorthand", async () => { + const { item, applied } = await completeFrom( + '', + 1, + "TestTagA", + ); + assert.ok(item, "expected a TestTagA completion"); + assert.ok(item.sortText?.startsWith("0"), "should be prioritized"); + assert.equal(applied, ''); + }); + + it("adds the closing `>` when not typed yet", async () => { + const { applied } = await completeFrom( + '', + 3, + "TestTagA", + ); + assert.equal(applied, ''); + }); + + it("offers the full shorthand for an empty value", async () => { + const { applied } = await completeFrom( + '', + 0, + "TestTagA", + ); + assert.equal(applied, ''); + }); + + it("completes relative paths after a slash", async () => { + const { item, applied } = await completeFrom( + '', + 2, + "index.marko", + ); + assert.ok(item, "expected a file completion"); + assert.equal(applied, ''); + }); +}); diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.html b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.html new file mode 100644 index 00000000..339c67e6 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.html @@ -0,0 +1,6 @@ +
+ + + +
placeholder
+
\ No newline at end of file diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.md b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.md new file mode 100644 index 00000000..0a40048a --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.md @@ -0,0 +1,12 @@ +## Hovers +### Ln 9, Col 12 +```marko + 7 | + 8 | <${input.content}/> +> 9 | + | ^ const label: string + 10 | // ^? + 11 |
${label.toUpperCase()}
+ 12 |
+``` + diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.ts b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.ts new file mode 100644 index 00000000..e6b7b690 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/labeled-region.ts @@ -0,0 +1,81 @@ +export interface Input { + label: string; + content?: Marko.Body; +} +function __marko_internal_template(this: void) { + const input = Marko._.any as Input; + const $signal = Marko._.any as AbortSignal; + const $global = Marko._.getGlobal( + // @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined. + (Marko._.error, Marko._.any as MarkoRun.Context), + ); + const label = Marko._.hoist(() => __marko_internal_hoist__label); + // Self-referencing consume: `from=` resolves to this same template. + var __marko_internal_context = () => input.label; + { + const __marko_internal_tag_1 = input.content; + Marko._.renderDynamicTag(__marko_internal_tag_1)()()({}); + { + const label = Marko._.contextTag(() => + Marko._.renderTemplate( + Marko._.resolveTemplate(import("./labeled-region.marko")), + )()()(Marko._.any), + ); + Marko._.renderNativeTag("div")()()( + // ^? + { + [Marko._.content]: (() => { + label.toUpperCase(); + return () => { + return Marko._.voidReturn; + }; + })(), + }, + ); + } + } + var { label: __marko_internal_hoist__label } = undefined; + Marko._.noop({ label, input, $global, $signal }); + return Marko._.withContext(__marko_internal_context, undefined); +} +const __marko_internal_api = "tags"; +export { __marko_internal_api as "~api" }; +const LabeledRegion = new (class Template extends Marko._.Template<{ + render( + input: Marko.TemplateInput, + stream?: { + write: (chunk: string) => void; + end: (chunk?: string) => void; + }, + ): Marko.Out; + + render( + input: Marko.TemplateInput, + cb?: (err: Error | null, result: Marko.RenderResult) => void, + ): Marko.Out; + + renderSync(input: Marko.TemplateInput): Marko.RenderResult; + + renderToString(input: Marko.TemplateInput): string; + + stream( + input: Marko.TemplateInput, + ): ReadableStream & NodeJS.ReadableStream; + + mount( + input: Marko.TemplateInput, + reference: Node, + position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend", + ): Marko.MountedTemplate; + + api: typeof __marko_internal_api; + _(): () => <__marko_internal_input extends unknown>( + input: Marko.Directives & + Input & + Marko._.Relate<__marko_internal_input, Marko.Directives & Input>, + ) => Marko._.ReturnWithScope< + __marko_internal_input, + typeof __marko_internal_template extends () => infer Return ? Return : never + >; +}> {})(); +export default LabeledRegion; diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.html b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.html new file mode 100644 index 00000000..07c5ab46 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.html @@ -0,0 +1,7 @@ +
+
+ +
+
+ +
\ No newline at end of file diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.md b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.ts b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.ts new file mode 100644 index 00000000..f42e00df --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/mode-provider.ts @@ -0,0 +1,74 @@ +export interface Input { + detailed?: boolean; + content?: Marko.Body; +} +abstract class Component extends Marko.Component {} +export { type Component }; +function __marko_internal_template(this: void) { + const input = Marko._.any as Input; + const component = Marko._.any as Component; + const state = Marko._.state(component); + const out = Marko._.any as Marko.Out; + const $signal = Marko._.any as AbortSignal; + const $global = Marko._.getGlobal( + // @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined. + (Marko._.error, Marko._.any as MarkoRun.Context), + ); + // The provide sits inside control flow: the consumed type must still be + // exactly the provided type, with no extractor-added `undefined` (reading + // an unprovided context is a runtime error, not a valid `undefined`). + if (input.detailed) { + var __marko_internal_context = () => + ({ mode: "detailed", level: 2 }) as const; + { + const __marko_internal_tag_1 = input.content; + Marko._.renderDynamicTag(__marko_internal_tag_1)()()({}); + } + } else { + const __marko_internal_tag_2 = input.content; + Marko._.renderDynamicTag(__marko_internal_tag_2)()()({}); + } + Marko._.noop({ component, state, out, input, $global, $signal }); + return Marko._.withContext(__marko_internal_context, undefined); +} +const __marko_internal_api = "class"; +export { __marko_internal_api as "~api" }; +const ModeProvider = new (class Template extends Marko._.Template<{ + render( + input: Marko.TemplateInput, + stream?: { + write: (chunk: string) => void; + end: (chunk?: string) => void; + }, + ): Marko.Out; + + render( + input: Marko.TemplateInput, + cb?: (err: Error | null, result: Marko.RenderResult) => void, + ): Marko.Out; + + renderSync(input: Marko.TemplateInput): Marko.RenderResult; + + renderToString(input: Marko.TemplateInput): string; + + stream( + input: Marko.TemplateInput, + ): ReadableStream & NodeJS.ReadableStream; + + mount( + input: Marko.TemplateInput, + reference: Node, + position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend", + ): Marko.MountedTemplate; + + api: typeof __marko_internal_api; + _(): () => <__marko_internal_input extends unknown>( + input: Marko.Directives & + Input & + Marko._.Relate<__marko_internal_input, Marko.Directives & Input>, + ) => Marko._.ReturnWithScope< + __marko_internal_input, + typeof __marko_internal_template extends () => infer Return ? Return : never + >; +}> {})(); +export default ModeProvider; diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.html b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.html new file mode 100644 index 00000000..75603206 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.html @@ -0,0 +1 @@ +placeholder \ No newline at end of file diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.md b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.md new file mode 100644 index 00000000..0a5fe04b --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.md @@ -0,0 +1,13 @@ +## Hovers +### Ln 1, Col 10 +```marko +> 1 | + | ^ const settings: { + readonly mode: "detailed"; + readonly level: 2; +} + 2 | // ^? + 3 | ${settings.mode} + 4 | +``` + diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.ts b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.ts new file mode 100644 index 00000000..c00ebed8 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-mode.ts @@ -0,0 +1,69 @@ +export interface Input {} +(function (this: void) { + const input = Marko._.any as Input; + const $signal = Marko._.any as AbortSignal; + const $global = Marko._.getGlobal( + // @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined. + (Marko._.error, Marko._.any as MarkoRun.Context), + ); + const settings = Marko._.hoist(() => __marko_internal_hoist__settings); + { + const settings = Marko._.contextTag(() => + Marko._.renderTemplate( + Marko._.resolveTemplate(import("./mode-provider.marko")), + )()()(Marko._.any), + ); + Marko._.renderNativeTag("span")()()( + // ^? + { + [Marko._.content]: (() => { + settings.mode; + return () => { + return Marko._.voidReturn; + }; + })(), + }, + ); + var __marko_internal_hoist__settings = settings; + } + Marko._.noop({ settings, input, $global, $signal }); + return; +})(); +const __marko_internal_api = "tags"; +export { __marko_internal_api as "~api" }; +const ShowMode = new (class Template extends Marko._.Template<{ + render( + input: Marko.TemplateInput, + stream?: { + write: (chunk: string) => void; + end: (chunk?: string) => void; + }, + ): Marko.Out; + + render( + input: Marko.TemplateInput, + cb?: (err: Error | null, result: Marko.RenderResult) => void, + ): Marko.Out; + + renderSync(input: Marko.TemplateInput): Marko.RenderResult; + + renderToString(input: Marko.TemplateInput): string; + + stream( + input: Marko.TemplateInput, + ): ReadableStream & NodeJS.ReadableStream; + + mount( + input: Marko.TemplateInput, + reference: Node, + position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend", + ): Marko.MountedTemplate; + + api: typeof __marko_internal_api; + _(): () => <__marko_internal_input extends unknown>( + input: Marko.Directives & + Input & + Marko._.Relate<__marko_internal_input, Marko.Directives & Input>, + ) => Marko._.ReturnWithScope<__marko_internal_input, void>; +}> {})(); +export default ShowMode; diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.html b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.html new file mode 100644 index 00000000..f44ce267 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.md b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.md new file mode 100644 index 00000000..46bd4069 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.md @@ -0,0 +1,10 @@ +## Hovers +### Ln 1, Col 10 +```marko +> 1 | + | ^ const theme: string + 2 | // ^? + 3 | + 4 | +``` + diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.ts b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.ts new file mode 100644 index 00000000..59443a21 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme-relative.ts @@ -0,0 +1,75 @@ +export interface Input {} +(function (this: void) { + const input = Marko._.any as Input; + const $signal = Marko._.any as AbortSignal; + const $global = Marko._.getGlobal( + // @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined. + (Marko._.error, Marko._.any as MarkoRun.Context), + ); + const theme = Marko._.hoist(() => __marko_internal_hoist__theme); + { + const theme = Marko._.contextTag(() => + Marko._.renderTemplate( + Marko._.resolveTemplate(import("./theme-provider.marko")), + )()()(Marko._.any), + ); + const __marko_internal_change__theme = Marko._.change("theme", { + themeChange: (value = theme) => {}, + }); + Marko._.renderNativeTag("button")()()( + // ^? + { + onClick() { + __marko_internal_change__theme.theme = "dark"; + }, + [Marko._.content]: (() => { + theme; + return () => { + return Marko._.voidReturn; + }; + })(), + }, + ); + var __marko_internal_hoist__theme = theme; + } + Marko._.noop({ theme, input, $global, $signal }); + return; +})(); +const __marko_internal_api = "tags"; +export { __marko_internal_api as "~api" }; +const ShowThemeRelative = new (class Template extends Marko._.Template<{ + render( + input: Marko.TemplateInput, + stream?: { + write: (chunk: string) => void; + end: (chunk?: string) => void; + }, + ): Marko.Out; + + render( + input: Marko.TemplateInput, + cb?: (err: Error | null, result: Marko.RenderResult) => void, + ): Marko.Out; + + renderSync(input: Marko.TemplateInput): Marko.RenderResult; + + renderToString(input: Marko.TemplateInput): string; + + stream( + input: Marko.TemplateInput, + ): ReadableStream & NodeJS.ReadableStream; + + mount( + input: Marko.TemplateInput, + reference: Node, + position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend", + ): Marko.MountedTemplate; + + api: typeof __marko_internal_api; + _(): () => <__marko_internal_input extends unknown>( + input: Marko.Directives & + Input & + Marko._.Relate<__marko_internal_input, Marko.Directives & Input>, + ) => Marko._.ReturnWithScope<__marko_internal_input, void>; +}> {})(); +export default ShowThemeRelative; diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.html b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.html new file mode 100644 index 00000000..a664f985 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.html @@ -0,0 +1 @@ +
placeholder
\ No newline at end of file diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.md b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.md new file mode 100644 index 00000000..4ac1fba3 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.md @@ -0,0 +1,10 @@ +## Hovers +### Ln 1, Col 10 +```marko +> 1 | + | ^ const theme: string + 2 | // ^? + 3 |
${theme.toUpperCase()}
+ 4 | +``` + diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.ts b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.ts new file mode 100644 index 00000000..61db4c4f --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/show-theme.ts @@ -0,0 +1,69 @@ +export interface Input {} +(function (this: void) { + const input = Marko._.any as Input; + const $signal = Marko._.any as AbortSignal; + const $global = Marko._.getGlobal( + // @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined. + (Marko._.error, Marko._.any as MarkoRun.Context), + ); + const theme = Marko._.hoist(() => __marko_internal_hoist__theme); + { + const theme = Marko._.contextTag(() => + Marko._.renderTemplate( + Marko._.resolveTemplate(import("./theme-provider.marko")), + )()()(Marko._.any), + ); + Marko._.renderNativeTag("div")()()( + // ^? + { + [Marko._.content]: (() => { + theme.toUpperCase(); + return () => { + return Marko._.voidReturn; + }; + })(), + }, + ); + var __marko_internal_hoist__theme = theme; + } + Marko._.noop({ theme, input, $global, $signal }); + return; +})(); +const __marko_internal_api = "tags"; +export { __marko_internal_api as "~api" }; +const ShowTheme = new (class Template extends Marko._.Template<{ + render( + input: Marko.TemplateInput, + stream?: { + write: (chunk: string) => void; + end: (chunk?: string) => void; + }, + ): Marko.Out; + + render( + input: Marko.TemplateInput, + cb?: (err: Error | null, result: Marko.RenderResult) => void, + ): Marko.Out; + + renderSync(input: Marko.TemplateInput): Marko.RenderResult; + + renderToString(input: Marko.TemplateInput): string; + + stream( + input: Marko.TemplateInput, + ): ReadableStream & NodeJS.ReadableStream; + + mount( + input: Marko.TemplateInput, + reference: Node, + position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend", + ): Marko.MountedTemplate; + + api: typeof __marko_internal_api; + _(): () => <__marko_internal_input extends unknown>( + input: Marko.Directives & + Input & + Marko._.Relate<__marko_internal_input, Marko.Directives & Input>, + ) => Marko._.ReturnWithScope<__marko_internal_input, void>; +}> {})(); +export default ShowTheme; diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.html b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.html new file mode 100644 index 00000000..5d02baf1 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.html @@ -0,0 +1,3 @@ +
+ +
\ No newline at end of file diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.md b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.ts b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.ts new file mode 100644 index 00000000..f661987a --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/components/theme-provider.ts @@ -0,0 +1,73 @@ +export interface Input { + start: string; + content?: Marko.Body; +} +function __marko_internal_template(this: void) { + const input = Marko._.any as Input; + const $signal = Marko._.any as AbortSignal; + const $global = Marko._.getGlobal( + // @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined. + (Marko._.error, Marko._.any as MarkoRun.Context), + ); + const theme = Marko._.hoist(() => __marko_internal_hoist__theme); + const __marko_internal_tag_1 = Marko._.resolveTemplate( + import("@marko/runtime-tags/tags/let.d.marko"), + ); + { + const theme = Marko._.returned(() => __marko_internal_rendered_1); + const __marko_internal_rendered_1 = Marko._.renderTemplate( + __marko_internal_tag_1, + )()()({ + value: input.start, + }); + var __marko_internal_context = () => theme; + { + const __marko_internal_tag_2 = input.content; + Marko._.renderDynamicTag(__marko_internal_tag_2)()()({}); + } + var __marko_internal_hoist__theme = theme; + } + Marko._.noop({ theme, input, $global, $signal }); + return Marko._.withContext(__marko_internal_context, undefined); +} +const __marko_internal_api = "tags"; +export { __marko_internal_api as "~api" }; +const ThemeProvider = new (class Template extends Marko._.Template<{ + render( + input: Marko.TemplateInput, + stream?: { + write: (chunk: string) => void; + end: (chunk?: string) => void; + }, + ): Marko.Out; + + render( + input: Marko.TemplateInput, + cb?: (err: Error | null, result: Marko.RenderResult) => void, + ): Marko.Out; + + renderSync(input: Marko.TemplateInput): Marko.RenderResult; + + renderToString(input: Marko.TemplateInput): string; + + stream( + input: Marko.TemplateInput, + ): ReadableStream & NodeJS.ReadableStream; + + mount( + input: Marko.TemplateInput, + reference: Node, + position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend", + ): Marko.MountedTemplate; + + api: typeof __marko_internal_api; + _(): () => <__marko_internal_input extends unknown>( + input: Marko.Directives & + Input & + Marko._.Relate<__marko_internal_input, Marko.Directives & Input>, + ) => Marko._.ReturnWithScope< + __marko_internal_input, + typeof __marko_internal_template extends () => infer Return ? Return : never + >; +}> {})(); +export default ThemeProvider; diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.html b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.html new file mode 100644 index 00000000..b4934e91 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.html @@ -0,0 +1,8 @@ +
+ + +
+ +
+ +
\ No newline at end of file diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.md b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.md new file mode 100644 index 00000000..244cd8f7 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.md @@ -0,0 +1,12 @@ +## Diagnostics +### Ln 5, Col 2 +```marko + 3 | + 4 | +> 5 | + | ^^^^^^^^^^^^^ Object literal may only specify known properties, and '[Marko._.contentFor(__marko_internal_tag_4)]' does not exist in type 'Directives & Input'. + 6 | + 7 | + 8 | +``` + diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.ts b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.ts new file mode 100644 index 00000000..149398ff --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/__snapshots__/context-tag.expected/index.ts @@ -0,0 +1,103 @@ +export interface Input {} +abstract class Component extends Marko.Component {} +export { type Component }; +(function (this: void) { + const input = Marko._.any as Input; + const component = Marko._.any as Component; + const state = Marko._.state(component); + const out = Marko._.any as Marko.Out; + const $signal = Marko._.any as AbortSignal; + const $global = Marko._.getGlobal( + // @ts-expect-error We expect the compiler to error because we are checking if the MarkoRun.Context is defined. + (Marko._.error, Marko._.any as MarkoRun.Context), + ); + const __marko_internal_tag_1 = Marko._.resolveTemplate( + import("./components/theme-provider.marko"), + ); + Marko._.renderTemplate(__marko_internal_tag_1)()()({ + start: "light", + [Marko._.contentFor(__marko_internal_tag_1)]: (() => { + const __marko_internal_tag_2 = Marko._.resolveTemplate( + import("./components/show-theme.marko"), + ); + Marko._.renderTemplate(__marko_internal_tag_2)()()({}); + const __marko_internal_tag_3 = Marko._.resolveTemplate( + import("./components/show-theme-relative.marko"), + ); + Marko._.renderTemplate(__marko_internal_tag_3)()()({}); + return () => { + return Marko._.voidReturn; + }; + })(), + }); + const __marko_internal_tag_4 = Marko._.resolveTemplate( + import("./components/mode-provider.marko"), + ); + Marko._.renderTemplate(__marko_internal_tag_4)()()({ + [Marko._.contentFor(__marko_internal_tag_4)]: (() => { + const __marko_internal_tag_5 = Marko._.resolveTemplate( + import("./components/show-mode.marko"), + ); + Marko._.renderTemplate(__marko_internal_tag_5)()()({}); + return () => { + return Marko._.voidReturn; + }; + })(), + }); + const __marko_internal_tag_6 = Marko._.resolveTemplate( + import("./components/labeled-region.marko"), + ); + Marko._.renderTemplate(__marko_internal_tag_6)()()({ + label: "outer", + [Marko._.contentFor(__marko_internal_tag_6)]: (() => { + const __marko_internal_tag_7 = Marko._.resolveTemplate( + import("./components/labeled-region.marko"), + ); + Marko._.renderTemplate(__marko_internal_tag_7)()()({ + label: "inner", + }); + return () => { + return Marko._.voidReturn; + }; + })(), + }); + Marko._.noop({ component, state, out, input, $global, $signal }); + return; +})(); +const __marko_internal_api = "class"; +export { __marko_internal_api as "~api" }; +export default new (class Template extends Marko._.Template<{ + render( + input: Marko.TemplateInput, + stream?: { + write: (chunk: string) => void; + end: (chunk?: string) => void; + }, + ): Marko.Out; + + render( + input: Marko.TemplateInput, + cb?: (err: Error | null, result: Marko.RenderResult) => void, + ): Marko.Out; + + renderSync(input: Marko.TemplateInput): Marko.RenderResult; + + renderToString(input: Marko.TemplateInput): string; + + stream( + input: Marko.TemplateInput, + ): ReadableStream & NodeJS.ReadableStream; + + mount( + input: Marko.TemplateInput, + reference: Node, + position?: "afterbegin" | "afterend" | "beforebegin" | "beforeend", + ): Marko.MountedTemplate; + + api: typeof __marko_internal_api; + _(): () => <__marko_internal_input extends unknown>( + input: Marko.Directives & + Input & + Marko._.Relate<__marko_internal_input, Marko.Directives & Input>, + ) => Marko._.ReturnWithScope<__marko_internal_input, void>; +}> {})(); diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/components/labeled-region.marko b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/labeled-region.marko new file mode 100644 index 00000000..edc35739 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/labeled-region.marko @@ -0,0 +1,12 @@ +export interface Input { + label: string; + content?: Marko.Body; +} + +// Self-referencing consume: `from=` resolves to this same template. + + <${input.content}/> + + // ^? +
${label.toUpperCase()}
+
diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/components/mode-provider.marko b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/mode-provider.marko new file mode 100644 index 00000000..f0d5b5ad --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/mode-provider.marko @@ -0,0 +1,16 @@ +export interface Input { + detailed?: boolean; + content?: Marko.Body; +} + +// The provide sits inside control flow: the consumed type must still be +// exactly the provided type, with no extractor-added `undefined` (reading +// an unprovided context is a runtime error, not a valid `undefined`). + + + <${input.content}/> +
+ + + <${input.content}/> + diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-mode.marko b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-mode.marko new file mode 100644 index 00000000..d20bfac9 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-mode.marko @@ -0,0 +1,3 @@ + +// ^? +${settings.mode} diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme-relative.marko b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme-relative.marko new file mode 100644 index 00000000..de50253c --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme-relative.marko @@ -0,0 +1,3 @@ + +// ^? + diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme.marko b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme.marko new file mode 100644 index 00000000..a736e66b --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/show-theme.marko @@ -0,0 +1,3 @@ + +// ^? +
${theme.toUpperCase()}
diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/components/theme-provider.marko b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/theme-provider.marko new file mode 100644 index 00000000..4a5abfaa --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/components/theme-provider.marko @@ -0,0 +1,9 @@ +export interface Input { + start: string; + content?: Marko.Body; +} + + + + <${input.content}/> +
diff --git a/packages/language-server/src/__tests__/fixtures/script/context-tag/index.marko b/packages/language-server/src/__tests__/fixtures/script/context-tag/index.marko new file mode 100644 index 00000000..82ed1fc6 --- /dev/null +++ b/packages/language-server/src/__tests__/fixtures/script/context-tag/index.marko @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/packages/language-server/src/service/marko/complete/AttrValue.ts b/packages/language-server/src/service/marko/complete/AttrValue.ts index 844346c7..e9169010 100644 --- a/packages/language-server/src/service/marko/complete/AttrValue.ts +++ b/packages/language-server/src/service/marko/complete/AttrValue.ts @@ -8,16 +8,47 @@ import { import fileSystem, { FileType } from "../../../utils/file-system"; import resolveUrl from "../../../utils/resolve-url"; +import getContextFromAttr from "../util/context-from-attr"; import isDocumentLinkAttr from "../util/is-document-link-attr"; +import getTagShorthandCompletions from "../util/tag-shorthand-completions"; import type { CompletionMeta } from "."; +// The shorthand tag name inside a `from=` string (eg `""` or `"\\]|\\.)*)(>?)/; + export async function AttrValue({ offset, node, - file: { uri, parsed, code }, + file, }: CompletionMeta): Promise { + const { uri, parsed, code } = file; const attr = node.parent; - if (isDocumentLinkAttr(code, attr.parent, attr)) { + const fromAttr = getContextFromAttr(code, attr.parent, attr); + + if (fromAttr) { + const start = node.value.start + 1; + const content = parsed.read({ start, end: node.value.end - 1 }); + const match = fromTagReg.exec(content); + if (match || !content) { + // Complete the `""` form; a `.`-led value falls through to + // the relative path completion below. + const [, name = "", close = ""] = match || []; + const nameStart = start + (match ? 1 : 0); + const items = getTagShorthandCompletions( + file, + { start: nameStart, end: nameStart + name.length }, + close ? "" : ">", + ); + if (!match) { + for (const item of items) { + item.textEdit!.newText = `<${item.textEdit!.newText}`; + } + } + return items; + } + } + + if (fromAttr || isDocumentLinkAttr(code, attr.parent, attr)) { const start = node.value.start + 1; if (code[start] !== ".") return; // only resolve relative paths diff --git a/packages/language-server/src/service/marko/complete/Import.ts b/packages/language-server/src/service/marko/complete/Import.ts index 5ba6dad8..9be9ad77 100644 --- a/packages/language-server/src/service/marko/complete/Import.ts +++ b/packages/language-server/src/service/marko/complete/Import.ts @@ -1,7 +1,6 @@ import { type Node, NodeType } from "@marko/language-tools"; -import { CompletionItem, TextEdit } from "vscode-languageserver"; -import getTagNameCompletion from "../util/get-tag-name-completion"; +import getTagShorthandCompletions from "../util/tag-shorthand-completions"; import type { CompletionMeta, CompletionResult } from "."; const staticImportReg = /^\s*(?:static|client|server) import\b/; @@ -11,9 +10,9 @@ const importTagReg = /(['"])<((?:[^'"\\>]|\\.)*)(>?)\1/; export function Import({ node, - file: { parsed, filename, lookup }, + file, }: CompletionMeta): CompletionResult { - const value = parsed.read(node); + const value = file.parsed.read(node); if (node.type === NodeType.Static && !staticImportReg.test(value)) { // Checks for `static import`, `client import` and `server import`. return; @@ -23,39 +22,10 @@ export function Import({ if (!match) return; const [, , name, close] = match; - // Complete just the tag name slot (between `<` and `>`) so it filters and - // sorts exactly like an open tag name, and append the closing `>` only when - // the user has not typed it yet so the shorthand stays valid. const nameStart = node.start + match.index + 2; // skip the opening quote and `<` - const range = parsed.locationAt({ - start: nameStart, - end: nameStart + name.length, - }); - const suffix = close ? "" : ">"; - - const result: CompletionItem[] = []; - - for (const tag of lookup.getTagsSorted()) { - if ( - (tag.template || tag.renderer) && - !( - tag.html || - tag.parser || - tag.translator || - tag.isNestedTag || - tag.name === "*" || - tag.parseOptions?.statement || - /^@?marko[/-]/.test(tag.taglibId) || - (tag.name[0] === "_" && /[\\/]node_modules[\\/]/.test(tag.filePath)) - ) - ) { - const completion = getTagNameCompletion({ tag, importer: filename }); - // Prioritize over TypeScript's module specifier completions. - completion.sortText = `0${completion.label}`; - completion.textEdit = TextEdit.replace(range, completion.label + suffix); - result.push(completion); - } - } - - return result; + return getTagShorthandCompletions( + file, + { start: nameStart, end: nameStart + name.length }, + close ? "" : ">", + ); } diff --git a/packages/language-server/src/service/marko/document-links.ts b/packages/language-server/src/service/marko/document-links.ts index a503d8c9..85af43bc 100644 --- a/packages/language-server/src/service/marko/document-links.ts +++ b/packages/language-server/src/service/marko/document-links.ts @@ -4,6 +4,7 @@ import type { DocumentLink } from "vscode-languageserver"; import { type MarkoFile, processDoc } from "../../utils/file"; import resolveUrl from "../../utils/resolve-url"; import type { Plugin } from "../types"; +import getContextFromAttr from "./util/context-from-attr"; import isDocumentLinkAttr from "./util/is-document-link-attr"; const importTagReg = /(['"])<((?:[^'"\\>]|\\.)*)>?\1/g; @@ -40,7 +41,25 @@ function extractDocumentLinks({ case NodeType.Tag: if (node.attrs && node.nameText) { for (const attr of node.attrs) { - if (isDocumentLinkAttr(code, node, attr)) { + const fromAttr = getContextFromAttr(code, node, attr); + if (fromAttr) { + const request = read(fromAttr.value.value).slice(1, -1); + const tagMatch = /^<(.*)>$/.exec(request); + const target = tagMatch + ? (() => { + const tagDef = lookup.getTag(tagMatch[1]); + return tagDef && (tagDef.template || tagDef.renderer); + })() + : request[0] === "." + ? resolveUrl(request, uri) + : undefined; + if (target) { + links.push({ + range: parsed.locationAt(fromAttr.value.value), + target, + }); + } + } else if (isDocumentLinkAttr(code, node, attr)) { const resolved = resolveUrl( read(attr.value.value).slice(1, -1), uri, diff --git a/packages/language-server/src/service/marko/util/context-from-attr.ts b/packages/language-server/src/service/marko/util/context-from-attr.ts new file mode 100644 index 00000000..e4abe1d2 --- /dev/null +++ b/packages/language-server/src/service/marko/util/context-from-attr.ts @@ -0,0 +1,16 @@ +import { type Node, NodeType } from "@marko/language-tools"; + +// ``: matches when the value is a quoted string literal. +export default function getContextFromAttr( + code: string, + tag: Node.ParentTag, + attr: Node.AttrNode, +): (Node.AttrNamed & { value: Node.AttrValue }) | undefined { + return tag.nameText === "context" && + attr.type === NodeType.AttrNamed && + code.slice(attr.name.start, attr.name.end) === "from" && + attr.value?.type === NodeType.AttrValue && + /^['"]$/.test(code[attr.value.value.start]) + ? (attr as Node.AttrNamed & { value: Node.AttrValue }) + : undefined; +} diff --git a/packages/language-server/src/service/marko/util/tag-shorthand-completions.ts b/packages/language-server/src/service/marko/util/tag-shorthand-completions.ts new file mode 100644 index 00000000..3b2d3484 --- /dev/null +++ b/packages/language-server/src/service/marko/util/tag-shorthand-completions.ts @@ -0,0 +1,39 @@ +import { type CompletionItem, TextEdit } from "vscode-languageserver"; + +import type { MarkoFile } from "../../../utils/file"; +import getTagNameCompletion from "./get-tag-name-completion"; + +// Completes the tag-name slot of a `""` shorthand string so it +// filters and sorts exactly like an open tag name; `close` appends the `>`. +export default function getTagShorthandCompletions( + { parsed, filename, lookup }: MarkoFile, + nameLoc: { start: number; end: number }, + close: string, +): CompletionItem[] { + const range = parsed.locationAt(nameLoc); + const result: CompletionItem[] = []; + + for (const tag of lookup.getTagsSorted()) { + if ( + (tag.template || tag.renderer) && + !( + tag.html || + tag.parser || + tag.translator || + tag.isNestedTag || + tag.name === "*" || + tag.parseOptions?.statement || + /^@?marko[/-]/.test(tag.taglibId) || + (tag.name[0] === "_" && /[\\/]node_modules[\\/]/.test(tag.filePath)) + ) + ) { + const completion = getTagNameCompletion({ tag, importer: filename }); + // Prioritize over TypeScript's module specifier completions. + completion.sortText = `0${completion.label}`; + completion.textEdit = TextEdit.replace(range, completion.label + close); + result.push(completion); + } + } + + return result; +} diff --git a/packages/language-tools/marko.internal.d.ts b/packages/language-tools/marko.internal.d.ts index 38474760..7d6d9ab4 100644 --- a/packages/language-tools/marko.internal.d.ts +++ b/packages/language-tools/marko.internal.d.ts @@ -31,6 +31,25 @@ declare global { rendered: () => T, ): T extends { return: { value: infer Returned } } ? Returned : never; + export const contextValue: unique symbol; + export function withContext( + context: Context, + returned: Return, + ): (Return extends void ? unknown : Return) & { + [contextValue]: [Context]; + }; + export function contextTag( + rendered: () => Rendered, + ): Rendered extends { return: infer Return } + ? [Return] extends [void] + ? unknown + : NonNullable extends { [contextValue]: [infer Context] } + ? NonNullable extends () => infer Value + ? Value + : unknown + : unknown + : unknown; + export function hoist( value: () => T, ): T extends () => infer R ? T & Iterable : never; diff --git a/packages/language-tools/src/extractors/script/index.ts b/packages/language-tools/src/extractors/script/index.ts index 7f4e334b..f2e4c748 100644 --- a/packages/language-tools/src/extractors/script/index.ts +++ b/packages/language-tools/src/extractors/script/index.ts @@ -326,9 +326,11 @@ class ScriptExtractor { } const didReturn = !!getReturnTag(program); - const templateName = didReturn ? varLocal("template") : ""; + const didProvide = !!this.#getContextProvideTag(program); + const namedTemplateFn = didReturn || didProvide; + const templateName = namedTemplateFn ? varLocal("template") : ""; - if (!didReturn) { + if (!namedTemplateFn) { this.#extractor.write("("); } @@ -411,7 +413,15 @@ function ${templateName}() {\n`); }${this.#api === RuntimeAPI.class ? "component, state, out, " : ""}input, $global, $signal });\n`, ); - if (didReturn) { + if (didProvide) { + // Threads the provided `` value's type out through the + // template's return type (see `withContext`/`contextTag`). + this.#extractor.write( + `return ${varShared("withContext")}(${varLocal("context")}, ${ + didReturn ? varLocal("return") : "undefined" + });\n}\n`, + ); + } else if (didReturn) { this.#extractor.write(`return ${varLocal("return")};\n}\n`); } else { this.#extractor.write("return;\n})();\n"); @@ -421,7 +431,7 @@ function ${templateName}() {\n`); const internalInput = varLocal("input"); const internalInputWithExtends = `${internalInput} extends unknown`; const internalApply = varLocal("apply"); - const returnTypeStr = didReturn + const returnTypeStr = namedTemplateFn ? `typeof ${ templateName + typeArgsStr } extends () => infer Return ? Return : never` @@ -610,6 +620,9 @@ constructor(_) {} case "return": returnTag = child; break; + case "context": + if (!this.#writeContextTag(child)) this.#writeTag(child); + break; case "if": { // @ts-expect-error we know we are in an If Tag declare const child: IfTag; @@ -788,6 +801,110 @@ constructor(_) {} return returnTag !== undefined; } + // ``: a provide (``) captures the provided value's + // type into a hoisted var the template's return threads out; a consume + // (``) types its variable off the resolved provider. + #writeContextTag(tag: Node.Tag): boolean { + const fromValue = this.#getAttrValue(tag, "from"); + + if (fromValue) { + if (!tag.var || hasHoists(tag)) return false; + const match = /^(['"])(.*)\1$/.exec(this.#read(fromValue).trim()); + let importPath: string | undefined; + + if (match) { + const request = match[2]; + const tagMatch = /^<(.*)>$/.exec(request); + importPath = tagMatch + ? resolveTagImport(this.#filename, this.#lookup.getTag(tagMatch[1])) + : request[0] === "." && request.endsWith(".marko") + ? request + : undefined; + } + + this.#writeComments(tag); + this.#closeBrackets[this.#closeBrackets.length - 1]++; + this.#extractor.write("{const "); + this.#extractor.copy(tag.var.value); + this.#extractor.write( + ` = ${ + importPath + ? `${varShared("contextTag")}(() => ${varShared( + "renderTemplate", + )}(${varShared( + "resolveTemplate", + )}(import("${importPath}")))()()(${varShared("any")}))` + : varShared("any") + };\n`, + ); + + const mutatedVars = getMutatedVars(tag); + if (mutatedVars) { + for (const binding of mutatedVars) { + // Same shape the generic tag-var path emits; the change data is + // synthesized so assignments type against the provided value. + this.#extractor.write( + `const ${varLocal(`change__${binding.name}`)} = ${varShared( + "change", + )}(${JSON.stringify(binding.name)}, { ${ + binding.name + }Change: (value = ${binding.name}) => {} });\n`, + ); + } + } + + return true; + } + + if (!this.#getAttrValue(tag, ATTR_UNNAMED) || tag.var) return false; + + this.#writeComments(tag); + // A thunk so the capture's type survives conditional/nested provide + // sites; a consumed context is guaranteed present (missing is a runtime + // error), so extraction unwraps to exactly the provided type. + this.#extractor.write(`var ${varLocal("context")} = () => (\n`); + this.#extractor.copy(this.#getAttrValue(tag, ATTR_UNNAMED)!); + this.#extractor.write("\n);\n"); + + const valueChange = this.#getAttrValue(tag, "valueChange"); + if (valueChange) { + this.#extractor.write(`${varShared("noop")}(\n`); + this.#extractor.copy(valueChange); + this.#extractor.write("\n);\n"); + } + + const body = this.#processBody(tag); + if (body?.content) { + this.#extractor.write("{\n"); + this.#writeChildren(body.content); + this.#endChildren(); + this.#extractor.write("\n}\n"); + } + + return true; + } + + #getContextProvideTag(parent: Node.ParentNode): Node.Tag | undefined { + if (parent.body) { + for (const child of parent.body) { + if (child.type === NodeType.Tag || child.type === NodeType.AttrTag) { + if ( + child.type === NodeType.Tag && + child.nameText === "context" && + !this.#getAttrValue(child, "from") && + this.#getAttrValue(child, ATTR_UNNAMED) && + !child.var + ) { + return child; + } + + const nested = this.#getContextProvideTag(child); + if (nested) return nested; + } + } + } + } + #writeTag(tag: Node.Tag) { const tagName = tag.nameText; const def = tagName ? this.#lookup.getTag(tagName) : undefined;