From 2abb87cfcb689c2582ad7e817a4d91978baa7920 Mon Sep 17 00:00:00 2001 From: Timothee Guerin Date: Thu, 18 Jun 2026 11:16:09 -0400 Subject: [PATCH] feat(playground): make raw tspconfig.yaml the source of truth with LSP The YAML config tab was a lossy view regenerated from structured compiler options, so raw edits (comments, output-dir, warn-as-error, ordering, unknown fields) were reverted, and the editor had no language-server support. - Persist the raw tspconfig.yaml as the source of truth; derive emitter/options by parsing it and write back from the Visual form preserving comments/fields. - Compile by writing tspconfig.yaml and resolving it natively via resolveCompilerOptions so the full config is honored. - Register language-server completion for the tspconfig.yaml editor. - Persist tspconfig in shared links; keep reading legacy e=/options= links. --- ...nfig-source-of-truth-2026-6-18-10-32-45.md | 7 ++ .../src/react/compilation/compile.ts | 34 +++++--- .../src/react/editor-panel/config-panel.tsx | 74 ++++++++---------- .../src/react/editor-panel/editor-panel.tsx | 6 ++ .../src/react/editor-panel/tspconfig-utils.ts | 56 +++++++++++++- .../src/react/hooks/use-compilation.ts | 10 +-- .../src/react/hooks/use-editor-actions.ts | 4 + packages/playground/src/react/playground.tsx | 7 +- packages/playground/src/react/standalone.tsx | 29 +++++-- .../src/react/use-playground-state.ts | 68 ++++++++++++---- packages/playground/src/services.ts | 45 ++++++++++- .../playground/test/tspconfig-utils.test.ts | 77 +++++++++++++++++++ 12 files changed, 335 insertions(+), 82 deletions(-) create mode 100644 .chronus/changes/playground-raw-tspconfig-source-of-truth-2026-6-18-10-32-45.md create mode 100644 packages/playground/test/tspconfig-utils.test.ts diff --git a/.chronus/changes/playground-raw-tspconfig-source-of-truth-2026-6-18-10-32-45.md b/.chronus/changes/playground-raw-tspconfig-source-of-truth-2026-6-18-10-32-45.md new file mode 100644 index 00000000000..84a858137a2 --- /dev/null +++ b/.chronus/changes/playground-raw-tspconfig-source-of-truth-2026-6-18-10-32-45.md @@ -0,0 +1,7 @@ +--- +changeKind: feature +packages: + - "@typespec/playground" +--- + +Make the raw `tspconfig.yaml` editor the source of truth so manual edits (comments, `output-dir`, `warn-as-error`, ordering and any unknown fields) are preserved instead of being reverted, compile by resolving the written `tspconfig.yaml` natively, and add language-server completion to the config editor. diff --git a/packages/playground/src/react/compilation/compile.ts b/packages/playground/src/react/compilation/compile.ts index ab48201cf6b..3b793f5812d 100644 --- a/packages/playground/src/react/compilation/compile.ts +++ b/packages/playground/src/react/compilation/compile.ts @@ -10,24 +10,38 @@ export async function compile( host: BrowserHost, content: string, selectedEmitter: string, - options: CompilerOptions, + tspconfig: string, ): Promise { await host.writeFile("main.tsp", content); + await host.writeFile("tspconfig.yaml", tspconfig); await emptyOutputDir(host); try { const typespecCompiler = host.compiler; - const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), { - ...options, + + // Resolve the compiler options natively from the tspconfig.yaml so the playground + // honors the full config (emit, options, linter, imports, warn-as-error, ...). + const [resolvedOptions] = await typespecCompiler.resolveCompilerOptions(host, { + cwd: resolveVirtualPath("."), + entrypoint: resolveVirtualPath("main.tsp"), + }); + + const options: CompilerOptions = { + ...resolvedOptions, options: { - ...options.options, - [selectedEmitter]: { - ...options.options?.[selectedEmitter], - "emitter-output-dir": outputDir, - }, + ...resolvedOptions.options, + ...(selectedEmitter + ? { + [selectedEmitter]: { + ...resolvedOptions.options?.[selectedEmitter], + "emitter-output-dir": outputDir, + }, + } + : {}), }, outputDir, - emit: selectedEmitter ? [selectedEmitter] : [], - }); + }; + + const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), options); const outputFiles = await findOutputFiles(host); return { program, outputFiles }; } catch (error) { diff --git a/packages/playground/src/react/editor-panel/config-panel.tsx b/packages/playground/src/react/editor-panel/config-panel.tsx index 798cb09015b..db1bfe6d57f 100644 --- a/packages/playground/src/react/editor-panel/config-panel.tsx +++ b/packages/playground/src/react/editor-panel/config-panel.tsx @@ -7,14 +7,16 @@ import { Editor, useMonacoModel } from "../editor.js"; import type { PlaygroundEditorsOptions } from "../playground.js"; import { CompilerSettings } from "../settings/compiler-settings.js"; import style from "./config-panel.module.css"; -import { compilerOptionsToTspConfig, parseTspConfigYaml } from "./tspconfig-utils.js"; export interface ConfigPanelProps { host: BrowserHost; selectedEmitter: string; compilerOptions: CompilerOptions; + /** Raw tspconfig.yaml content (source of truth). */ + tspconfig: string; onCompilerOptionsChange: (options: CompilerOptions) => void; onSelectedEmitterChange: (emitter: string) => void; + onTspconfigChange: (tspconfig: string) => void; editorOptions?: PlaygroundEditorsOptions; } @@ -24,70 +26,56 @@ export const ConfigPanel: FunctionComponent = ({ host, selectedEmitter, compilerOptions, + tspconfig, onCompilerOptionsChange, onSelectedEmitterChange, + onTspconfigChange, editorOptions, }) => { const [mode, setMode] = useState("form"); const yamlModel = useMonacoModel("inmemory://test/tspconfig.yaml", "yaml"); - // Tracks whether the last state change originated from the YAML editor. - // Persists across the render cycle so the sync-back effect can see it. + // Tracks whether the last model change originated from the YAML editor itself so the + // state → model sync effect doesn't clobber in-flight edits (state updates are debounced). const changeFromYamlRef = useRef(false); - // Sync external changes (e.g. emitter dropdown) → YAML model when in yaml mode. - // Skips when the change originated from the YAML editor itself. - useEffect(() => { - if (mode !== "yaml") return; - if (changeFromYamlRef.current) { - changeFromYamlRef.current = false; - return; - } - const yaml = compilerOptionsToTspConfig(selectedEmitter, compilerOptions); - const current = yamlModel.getValue(); - if (current !== yaml) { - yamlModel.setValue(yaml); - } - }, [selectedEmitter, compilerOptions, mode, yamlModel]); - - // Debounced YAML → CompilerOptions parsing - const parseAndSync = useMemo( + // Debounced YAML → state propagation. The raw text is the source of truth so it is + // stored verbatim (comments, ordering and unknown fields are all preserved). + const propagateChange = useMemo( () => debounce((content: string) => { - const parsed = parseTspConfigYaml(content); - if (!parsed) return; // Invalid YAML — don't touch state - changeFromYamlRef.current = true; - if (parsed.selectedEmitter && parsed.selectedEmitter !== selectedEmitter) { - onSelectedEmitterChange(parsed.selectedEmitter); - } - onCompilerOptionsChange(parsed.compilerOptions); + onTspconfigChange(content); }, 200), - [selectedEmitter, onCompilerOptionsChange, onSelectedEmitterChange], + [onTspconfigChange], ); // Listen for YAML model changes useEffect(() => { const disposable = yamlModel.onDidChangeContent(() => { - parseAndSync(yamlModel.getValue()); + changeFromYamlRef.current = true; + propagateChange(yamlModel.getValue()); }); return () => { - parseAndSync.clear(); + propagateChange.clear(); disposable.dispose(); }; - }, [yamlModel, parseAndSync]); + }, [yamlModel, propagateChange]); - // Populate YAML model when switching to yaml mode - const handleModeChange = useCallback( - (_, data) => { - const newMode = data.value as ConfigMode; - if (newMode === "yaml") { - const yaml = compilerOptionsToTspConfig(selectedEmitter, compilerOptions); - yamlModel.setValue(yaml); - } - setMode(newMode); - }, - [selectedEmitter, compilerOptions, yamlModel], - ); + // Sync state → YAML model (initial load, visual-form edits, samples, external changes). + // Skips when the change originated from the YAML editor to avoid reverting live edits. + useEffect(() => { + if (changeFromYamlRef.current) { + changeFromYamlRef.current = false; + return; + } + if (yamlModel.getValue() !== tspconfig) { + yamlModel.setValue(tspconfig); + } + }, [tspconfig, yamlModel]); + + const handleModeChange = useCallback((_, data) => { + setMode(data.value as ConfigMode); + }, []); return (
diff --git a/packages/playground/src/react/editor-panel/editor-panel.tsx b/packages/playground/src/react/editor-panel/editor-panel.tsx index 11369bb894d..dfe126cb9dd 100644 --- a/packages/playground/src/react/editor-panel/editor-panel.tsx +++ b/packages/playground/src/react/editor-panel/editor-panel.tsx @@ -37,8 +37,10 @@ export interface EditorPanelProps { selectedEmitter: string; compilerOptions: CompilerOptions; + tspconfig: string; onCompilerOptionsChange: (options: CompilerOptions) => void; onSelectedEmitterChange: (emitter: string) => void; + onTspconfigChange: (tspconfig: string) => void; /** Toolbar content rendered above the editor area */ commandBar?: ReactNode; @@ -52,8 +54,10 @@ export const EditorPanel: FunctionComponent = ({ onMount, selectedEmitter, compilerOptions, + tspconfig, onCompilerOptionsChange, onSelectedEmitterChange, + onTspconfigChange, commandBar, }) => { const [selectedTab, setSelectedTab] = useState("tsp"); @@ -92,8 +96,10 @@ export const EditorPanel: FunctionComponent = ({ host={host} selectedEmitter={selectedEmitter} compilerOptions={compilerOptions} + tspconfig={tspconfig} onCompilerOptionsChange={onCompilerOptionsChange} onSelectedEmitterChange={onSelectedEmitterChange} + onTspconfigChange={onTspconfigChange} editorOptions={editorOptions} /> )} diff --git a/packages/playground/src/react/editor-panel/tspconfig-utils.ts b/packages/playground/src/react/editor-panel/tspconfig-utils.ts index b0a2506ad3e..fa083a50690 100644 --- a/packages/playground/src/react/editor-panel/tspconfig-utils.ts +++ b/packages/playground/src/react/editor-panel/tspconfig-utils.ts @@ -1,5 +1,5 @@ import type { CompilerOptions, LinterRuleSet } from "@typespec/compiler"; -import { parse, stringify } from "yaml"; +import { isMap, parse, parseDocument, stringify } from "yaml"; export interface TspConfig { emit?: string[]; @@ -11,6 +11,15 @@ export interface TspConfig { "output-dir"?: string; } +function hasLinterRules(linterRuleSet: LinterRuleSet | undefined): boolean { + if (!linterRuleSet) return false; + return Boolean( + (linterRuleSet.extends && linterRuleSet.extends.length > 0) || + (linterRuleSet.enable && Object.keys(linterRuleSet.enable).length > 0) || + (linterRuleSet.disable && Object.keys(linterRuleSet.disable).length > 0), + ); +} + /** * Serialize the current playground state (emitter + compiler options) to tspconfig.yaml content. */ @@ -28,13 +37,56 @@ export function compilerOptionsToTspConfig( config.options = compilerOptions.options; } - if (compilerOptions.linterRuleSet) { + if (hasLinterRules(compilerOptions.linterRuleSet)) { config.linter = compilerOptions.linterRuleSet; } return stringify(config, { indent: 2 }) || ""; } +/** + * Update an existing tspconfig.yaml with structured changes coming from the visual form + * (emitter + compiler options) while preserving comments and any other fields the form + * doesn't manage (e.g. `output-dir`, `warn-as-error`, `imports`). + */ +export function updateTspConfigYaml( + existingYaml: string, + selectedEmitter: string, + compilerOptions: CompilerOptions, +): string { + let doc; + try { + doc = parseDocument(existingYaml ?? ""); + } catch { + return compilerOptionsToTspConfig(selectedEmitter, compilerOptions); + } + + // If the existing content isn't a clean mapping (empty/invalid), rebuild from scratch. + if (doc.errors.length > 0 || !isMap(doc.contents)) { + return compilerOptionsToTspConfig(selectedEmitter, compilerOptions); + } + + if (selectedEmitter) { + doc.setIn(["emit"], [selectedEmitter]); + } else { + doc.deleteIn(["emit"]); + } + + if (compilerOptions.options && Object.keys(compilerOptions.options).length > 0) { + doc.setIn(["options"], compilerOptions.options); + } else { + doc.deleteIn(["options"]); + } + + if (hasLinterRules(compilerOptions.linterRuleSet)) { + doc.setIn(["linter"], compilerOptions.linterRuleSet); + } else { + doc.deleteIn(["linter"]); + } + + return doc.toString(); +} + export interface ParsedTspConfig { selectedEmitter?: string; compilerOptions: CompilerOptions; diff --git a/packages/playground/src/react/hooks/use-compilation.ts b/packages/playground/src/react/hooks/use-compilation.ts index 08ae6038876..3b1c2a404d1 100644 --- a/packages/playground/src/react/hooks/use-compilation.ts +++ b/packages/playground/src/react/hooks/use-compilation.ts @@ -1,4 +1,3 @@ -import type { CompilerOptions } from "@typespec/compiler"; import { $ } from "@typespec/compiler/typekit"; import { MarkerSeverity, MarkerTag, editor } from "monaco-editor"; import { useCallback, useEffect, useRef, useState } from "react"; @@ -11,7 +10,8 @@ import type { CompilationState } from "../types.js"; export interface UseCompilationOptions { host: BrowserHost; selectedEmitter: string; - compilerOptions: CompilerOptions; + /** Raw tspconfig.yaml content used to configure the compilation. */ + tspconfig: string; typespecModel: editor.ITextModel; } @@ -25,7 +25,7 @@ export interface UseCompilationResult { export function useCompilation({ host, selectedEmitter, - compilerOptions, + tspconfig, typespecModel, }: UseCompilationOptions): UseCompilationResult { const [compilationState, setCompilationState] = useState(undefined); @@ -57,7 +57,7 @@ export function useCompilation({ setIsCompiling(true); let state: CompilationState; try { - state = await compile(host, currentContent, selectedEmitter, compilerOptions); + state = await compile(host, currentContent, selectedEmitter, tspconfig); } catch (error) { // eslint-disable-next-line no-console console.error("Compilation failed", error); @@ -121,7 +121,7 @@ export function useCompilation({ pendingRecompileRef.current = false; void doCompileRef.current(); } - }, [host, selectedEmitter, compilerOptions, typespecModel]); + }, [host, selectedEmitter, tspconfig, typespecModel]); useEffect(() => { doCompileRef.current = doCompile; diff --git a/packages/playground/src/react/hooks/use-editor-actions.ts b/packages/playground/src/react/hooks/use-editor-actions.ts index bb523e9ab11..f4810014361 100644 --- a/packages/playground/src/react/hooks/use-editor-actions.ts +++ b/packages/playground/src/react/hooks/use-editor-actions.ts @@ -15,6 +15,7 @@ export interface UseEditorActionsOptions { editorRef: RefObject; selectedEmitter: string; compilerOptions: CompilerOptions; + tspconfig: string; selectedSampleName: string; isSampleUntouched: boolean; selectedViewer?: string; @@ -35,6 +36,7 @@ export function useEditorActions({ editorRef, selectedEmitter, compilerOptions, + tspconfig, selectedSampleName, isSampleUntouched, selectedViewer, @@ -49,6 +51,7 @@ export function useEditorActions({ content: currentContent, emitter: selectedEmitter, compilerOptions, + tspconfig, sampleName: isSampleUntouched ? selectedSampleName : undefined, selectedViewer, viewerState, @@ -59,6 +62,7 @@ export function useEditorActions({ onSave, selectedEmitter, compilerOptions, + tspconfig, selectedSampleName, isSampleUntouched, selectedViewer, diff --git a/packages/playground/src/react/playground.tsx b/packages/playground/src/react/playground.tsx index 583c72d37bb..33b5d199a49 100644 --- a/packages/playground/src/react/playground.tsx +++ b/packages/playground/src/react/playground.tsx @@ -176,12 +176,14 @@ export const Playground: FunctionComponent = (props) => { const { selectedEmitter, compilerOptions, + tspconfig, selectedSampleName, selectedViewer, viewerState, content, onSelectedEmitterChange, onCompilerOptionsChange, + onTspconfigChange, onSelectedSampleNameChange, onSelectedViewerChange, onViewerStateChange, @@ -195,7 +197,7 @@ export const Playground: FunctionComponent = (props) => { const { compilationState, isCompiling, isOutputStale, doCompile } = useCompilation({ host, selectedEmitter, - compilerOptions, + tspconfig, typespecModel, }); @@ -219,6 +221,7 @@ export const Playground: FunctionComponent = (props) => { editorRef, selectedEmitter, compilerOptions, + tspconfig, selectedSampleName, isSampleUntouched, selectedViewer, @@ -301,8 +304,10 @@ export const Playground: FunctionComponent = (props) => { onMount={onTypeSpecEditorMount} selectedEmitter={selectedEmitter} compilerOptions={compilerOptions} + tspconfig={tspconfig} onCompilerOptionsChange={onCompilerOptionsChange} onSelectedEmitterChange={onSelectedEmitterChange} + onTspconfigChange={onTspconfigChange} commandBar={isMobile ? undefined : commandBar} /> ); diff --git a/packages/playground/src/react/standalone.tsx b/packages/playground/src/react/standalone.tsx index e5d4298833f..efb951526f0 100644 --- a/packages/playground/src/react/standalone.tsx +++ b/packages/playground/src/react/standalone.tsx @@ -99,12 +99,14 @@ export const StandalonePlayground: FunctionComponent = (c const onPlaygroundStateChange = useCallback( (newState: PlaygroundState) => { - // Auto-save state changes to storage without showing toast - // Preserve the last known content or use empty string if none + // Auto-save state changes to storage without showing toast. + // Preserve the last known content or use empty string if none. + // `emitter`/`compilerOptions` are intentionally omitted — `tspconfig` is the + // source of truth and the only config persisted on save. const saveData: PlaygroundSaveData = { content: lastSavedData?.content || "", - emitter: newState.emitter || "", - compilerOptions: newState.compilerOptions, + emitter: "", + tspconfig: newState.tspconfig, sampleName: newState.sampleName, selectedViewer: newState.selectedViewer, viewerState: newState.viewerState, @@ -124,6 +126,7 @@ export const StandalonePlayground: FunctionComponent = (c emitter: context.initialState.emitter ?? config.defaultPlaygroundState?.emitter, compilerOptions: context.initialState.compilerOptions ?? config.defaultPlaygroundState?.compilerOptions, + tspconfig: context.initialState.tspconfig ?? config.defaultPlaygroundState?.tspconfig, sampleName: context.initialState.sampleName ?? config.defaultPlaygroundState?.sampleName, selectedViewer: context.initialState.selectedViewer ?? config.defaultPlaygroundState?.selectedViewer, @@ -173,6 +176,9 @@ export function createStandalonePlaygroundStateStorage(): UrlStateStorage; @@ -54,6 +63,7 @@ export interface PlaygroundStateResult { // State setters onSelectedEmitterChange: (emitter: string) => void; onCompilerOptionsChange: (compilerOptions: CompilerOptions) => void; + onTspconfigChange: (tspconfig: string) => void; onSelectedSampleNameChange: (sampleName: string) => void; onSelectedViewerChange: (selectedViewer: string) => void; onViewerStateChange: (viewerState: Record) => void; @@ -96,12 +106,27 @@ export function usePlaygroundState({ onPlaygroundStateChange, ); - // Extract individual values from the consolidated state with proper defaults - const selectedEmitter = playgroundState.emitter as any; - const compilerOptions = useMemo( - () => playgroundState.compilerOptions ?? {}, - [playgroundState.compilerOptions], + // Extract individual values from the consolidated state with proper defaults. + // The raw tspconfig.yaml is the source of truth for the compiler configuration. + // For backwards compatibility (older shared links / samples that only provide the + // structured `emitter` + `compilerOptions`), synthesize the YAML when none is stored. + const tspconfig = useMemo( + () => + playgroundState.tspconfig ?? + compilerOptionsToTspConfig( + playgroundState.emitter ?? "", + playgroundState.compilerOptions ?? {}, + ), + [playgroundState.tspconfig, playgroundState.emitter, playgroundState.compilerOptions], ); + + const parsedConfig = useMemo( + () => parseTspConfigYaml(tspconfig) ?? { compilerOptions: {} }, + [tspconfig], + ); + + const selectedEmitter = (parsedConfig.selectedEmitter ?? "") as any; + const compilerOptions = parsedConfig.compilerOptions; const selectedSampleName = playgroundState.sampleName ?? ""; const selectedViewer = playgroundState.selectedViewer; const content = playgroundState.content ?? ""; @@ -114,14 +139,23 @@ export function usePlaygroundState({ [playgroundState, setPlaygroundState], ); - // Simple one-liner change handlers - const onSelectedEmitterChange = useCallback( - (emitter: string) => updateState({ emitter }), + // Raw YAML edits are persisted directly — this is the source of truth. + const onTspconfigChange = useCallback( + (tspconfig: string) => updateState({ tspconfig }), [updateState], ); + + // Visual form changes are written back into the YAML, preserving comments and any + // fields the form doesn't manage. + const onSelectedEmitterChange = useCallback( + (emitter: string) => + updateState({ tspconfig: updateTspConfigYaml(tspconfig, emitter, compilerOptions) }), + [updateState, tspconfig, compilerOptions], + ); const onCompilerOptionsChange = useCallback( - (compilerOptions: CompilerOptions) => updateState({ compilerOptions }), - [updateState], + (newOptions: CompilerOptions) => + updateState({ tspconfig: updateTspConfigYaml(tspconfig, selectedEmitter, newOptions) }), + [updateState, tspconfig, selectedEmitter], ); const onSelectedSampleNameChange = useCallback( (sampleName: string) => updateState({ sampleName }), @@ -147,11 +181,13 @@ export function usePlaygroundState({ if (config?.content) { lastProcessedSample.current = selectedSampleName; const updates: Partial = { content: config.content }; - if (config.preferredEmitter) { - updates.emitter = config.preferredEmitter; - } - if (config.compilerOptions) { - updates.compilerOptions = config.compilerOptions; + // Samples are authored with structured emitter/options — convert them into the + // raw tspconfig.yaml which is the playground's source of truth. + if (config.preferredEmitter || config.compilerOptions) { + updates.tspconfig = compilerOptionsToTspConfig( + config.preferredEmitter ?? "", + config.compilerOptions ?? {}, + ); } updateState(updates); } @@ -162,6 +198,7 @@ export function usePlaygroundState({ // State values selectedEmitter, compilerOptions, + tspconfig, selectedSampleName, selectedViewer, viewerState: playgroundState.viewerState ?? {}, @@ -170,6 +207,7 @@ export function usePlaygroundState({ // State setters onSelectedEmitterChange, onCompilerOptionsChange, + onTspconfigChange, onSelectedSampleNameChange, onSelectedViewerChange, onViewerStateChange, diff --git a/packages/playground/src/services.ts b/packages/playground/src/services.ts index 5bdba2a53c4..77ebca37944 100644 --- a/packages/playground/src/services.ts +++ b/packages/playground/src/services.ts @@ -68,6 +68,10 @@ export async function registerMonacoLanguage(host: BrowserHost) { monaco.languages.register({ id: "typespec", extensions: [".tsp"] }); monaco.languages.setLanguageConfiguration("typespec", getTypeSpecLanguageConfiguration()); + // tspconfig.yaml is edited as a `yaml` model and gets LSP completion from the + // TypeSpec language server (see the completion provider registered below). + monaco.languages.register({ id: "yaml", extensions: [".yaml", ".yml"] }); + if ((window as any).registeredServices) { return; } @@ -345,7 +349,46 @@ export async function registerMonacoLanguage(host: BrowserHost) { }, }); - // Register a code action provider that uses the playground's own compilation + // tspconfig.yaml completion backed by the TypeSpec language server. Scoped to + // `tspconfig.yaml` documents only (not every yaml file) via the language filter + // pattern. The server routes config-specific completions based on the document URI. + monaco.languages.registerCompletionItemProvider( + { language: "yaml", pattern: "**/tspconfig.yaml" }, + { + triggerCharacters: [":", " ", "/", "-", ".", '"'], + async provideCompletionItems(model, position) { + const result = await serverLib.complete(lspArgs(model, position)); + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + const suggestions: monaco.languages.CompletionItem[] = []; + for (const item of result.items) { + let itemRange: monaco.IRange = range; + let insertText = item.insertText ?? item.label; + if (item.textEdit && "range" in item.textEdit) { + itemRange = LspToMonaco.range(item.textEdit.range); + insertText = item.textEdit.newText; + } + suggestions.push({ + label: item.label, + kind: item.kind as any, + documentation: item.documentation, + insertText, + range: itemRange, + commitCharacters: item.commitCharacters, + tags: item.tags, + }); + } + + return { suggestions }; + }, + }, + ); // diagnostics (which include codefixes) rather than the LSP server diagnostics. monaco.languages.registerCodeActionProvider("typespec", { async provideCodeActions(model, range) { diff --git a/packages/playground/test/tspconfig-utils.test.ts b/packages/playground/test/tspconfig-utils.test.ts new file mode 100644 index 00000000000..36cdd9f46df --- /dev/null +++ b/packages/playground/test/tspconfig-utils.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import { parse } from "yaml"; +import { + compilerOptionsToTspConfig, + parseTspConfigYaml, + updateTspConfigYaml, +} from "../src/react/editor-panel/tspconfig-utils.js"; + +describe("compilerOptionsToTspConfig", () => { + it("serializes emitter, options and linter", () => { + const yaml = compilerOptionsToTspConfig("@typespec/openapi3", { + options: { "@typespec/openapi3": { "file-type": "json" } }, + linterRuleSet: { extends: ["@typespec/best-practices/recommended"] }, + }); + expect(parse(yaml)).toEqual({ + emit: ["@typespec/openapi3"], + options: { "@typespec/openapi3": { "file-type": "json" } }, + linter: { extends: ["@typespec/best-practices/recommended"] }, + }); + }); + + it("omits an empty linter rule set", () => { + const yaml = compilerOptionsToTspConfig("@typespec/openapi3", { linterRuleSet: {} }); + expect(parse(yaml)).toEqual({ emit: ["@typespec/openapi3"] }); + }); +}); + +describe("updateTspConfigYaml", () => { + it("preserves comments and unknown fields when updating from the form", () => { + const existing = [ + "# my project config", + "emit:", + ' - "@typespec/openapi3"', + 'output-dir: "{cwd}/tsp-output"', + "warn-as-error: true", + "", + ].join("\n"); + + const result = updateTspConfigYaml(existing, "@typespec/json-schema", { + options: { "@typespec/json-schema": { "file-type": "yaml" } }, + }); + + expect(result).toContain("# my project config"); + const parsed = parse(result); + expect(parsed.emit).toEqual(["@typespec/json-schema"]); + expect(parsed["output-dir"]).toBe("{cwd}/tsp-output"); + expect(parsed["warn-as-error"]).toBe(true); + expect(parsed.options).toEqual({ "@typespec/json-schema": { "file-type": "yaml" } }); + }); + + it("removes emit when no emitter is selected", () => { + const existing = 'emit:\n - "@typespec/openapi3"\n'; + const result = updateTspConfigYaml(existing, "", {}); + expect(parse(result)?.emit).toBeUndefined(); + }); + + it("rebuilds from scratch when the existing content is empty", () => { + const result = updateTspConfigYaml("", "@typespec/openapi3", {}); + expect(parse(result)).toEqual({ emit: ["@typespec/openapi3"] }); + }); +}); + +describe("parseTspConfigYaml", () => { + it("extracts emitter and options", () => { + const parsed = parseTspConfigYaml( + "emit:\n - '@typespec/openapi3'\noptions:\n '@typespec/openapi3':\n file-type: json\n", + ); + expect(parsed?.selectedEmitter).toBe("@typespec/openapi3"); + expect(parsed?.compilerOptions.options).toEqual({ + "@typespec/openapi3": { "file-type": "json" }, + }); + }); + + it("returns undefined for invalid yaml", () => { + expect(parseTspConfigYaml("emit: [\n")).toBeUndefined(); + }); +});