Skip to content

Commit 388eecc

Browse files
feat(playground): make raw tspconfig.yaml the source of truth with LSP (#11016)
## Problem The playground "Yaml" config tab (added in #9843) was a **lossy view** regenerated from the structured `compilerOptions` on every mode/tab switch. As a result: - Raw edits — comments, `output-dir`, `warn-as-error`, key ordering, and any field the visual form doesn't model — were **reverted** when leaving the editor. - The editor was a plain `yaml` model with **no LSP**, even though the TypeSpec language server already supports `tspconfig.yaml` completion. ## Change Make the raw `tspconfig.yaml` the source of truth. - **Persistence / no revert** — the raw YAML string is stored in playground state. `selectedEmitter`/`compilerOptions` are derived by parsing it; the Visual form and emitter dropdown write back via `updateTspConfigYaml`, which uses the `yaml` Document API to **preserve comments and unknown fields**. - **Native config resolution** — compilation writes `tspconfig.yaml` to the virtual FS and resolves it via `resolveCompilerOptions`, so the playground honors the full config (`emit`, `options`, `linter`, `imports`, `warn-as-error`, …) like the real CLI. - **LSP** — registered the `yaml` language and a completion provider for models ending in `tspconfig.yaml`, backed by the language server. <img width="590" height="379" alt="image" src="https://github.com/user-attachments/assets/3c57c788-9ca0-436c-aacd-f0ed6476f456" /> - **Sharing** — `tspconfig` is persisted in shared links. Legacy `e=`/`options=` links are still read for back-compat, but only `tspconfig=` is written on save (stale legacy params are cleared). ## Notes - The language server does not emit diagnostics for `tspconfig.yaml` (completion only), so config schema errors aren't shown inline — consistent with existing server behavior; possible follow-up. ## Tests - New `tspconfig-utils.test.ts` covers comment/field preservation, emit removal, rebuild-from-empty, and parsing round-trips. - All 48 playground tests pass; package builds (type-check) and lint are clean.
1 parent c81d9d9 commit 388eecc

12 files changed

Lines changed: 335 additions & 82 deletions
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@typespec/playground"
5+
---
6+
7+
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.

packages/playground/src/react/compilation/compile.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,38 @@ export async function compile(
1010
host: BrowserHost,
1111
content: string,
1212
selectedEmitter: string,
13-
options: CompilerOptions,
13+
tspconfig: string,
1414
): Promise<CompilationState> {
1515
await host.writeFile("main.tsp", content);
16+
await host.writeFile("tspconfig.yaml", tspconfig);
1617
await emptyOutputDir(host);
1718
try {
1819
const typespecCompiler = host.compiler;
19-
const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), {
20-
...options,
20+
21+
// Resolve the compiler options natively from the tspconfig.yaml so the playground
22+
// honors the full config (emit, options, linter, imports, warn-as-error, ...).
23+
const [resolvedOptions] = await typespecCompiler.resolveCompilerOptions(host, {
24+
cwd: resolveVirtualPath("."),
25+
entrypoint: resolveVirtualPath("main.tsp"),
26+
});
27+
28+
const options: CompilerOptions = {
29+
...resolvedOptions,
2130
options: {
22-
...options.options,
23-
[selectedEmitter]: {
24-
...options.options?.[selectedEmitter],
25-
"emitter-output-dir": outputDir,
26-
},
31+
...resolvedOptions.options,
32+
...(selectedEmitter
33+
? {
34+
[selectedEmitter]: {
35+
...resolvedOptions.options?.[selectedEmitter],
36+
"emitter-output-dir": outputDir,
37+
},
38+
}
39+
: {}),
2740
},
2841
outputDir,
29-
emit: selectedEmitter ? [selectedEmitter] : [],
30-
});
42+
};
43+
44+
const program = await typespecCompiler.compile(host, resolveVirtualPath("main.tsp"), options);
3145
const outputFiles = await findOutputFiles(host);
3246
return { program, outputFiles };
3347
} catch (error) {

packages/playground/src/react/editor-panel/config-panel.tsx

Lines changed: 31 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import { Editor, useMonacoModel } from "../editor.js";
77
import type { PlaygroundEditorsOptions } from "../playground.js";
88
import { CompilerSettings } from "../settings/compiler-settings.js";
99
import style from "./config-panel.module.css";
10-
import { compilerOptionsToTspConfig, parseTspConfigYaml } from "./tspconfig-utils.js";
1110

1211
export interface ConfigPanelProps {
1312
host: BrowserHost;
1413
selectedEmitter: string;
1514
compilerOptions: CompilerOptions;
15+
/** Raw tspconfig.yaml content (source of truth). */
16+
tspconfig: string;
1617
onCompilerOptionsChange: (options: CompilerOptions) => void;
1718
onSelectedEmitterChange: (emitter: string) => void;
19+
onTspconfigChange: (tspconfig: string) => void;
1820
editorOptions?: PlaygroundEditorsOptions;
1921
}
2022

@@ -24,70 +26,56 @@ export const ConfigPanel: FunctionComponent<ConfigPanelProps> = ({
2426
host,
2527
selectedEmitter,
2628
compilerOptions,
29+
tspconfig,
2730
onCompilerOptionsChange,
2831
onSelectedEmitterChange,
32+
onTspconfigChange,
2933
editorOptions,
3034
}) => {
3135
const [mode, setMode] = useState<ConfigMode>("form");
3236
const yamlModel = useMonacoModel("inmemory://test/tspconfig.yaml", "yaml");
3337

34-
// Tracks whether the last state change originated from the YAML editor.
35-
// Persists across the render cycle so the sync-back effect can see it.
38+
// Tracks whether the last model change originated from the YAML editor itself so the
39+
// state → model sync effect doesn't clobber in-flight edits (state updates are debounced).
3640
const changeFromYamlRef = useRef(false);
3741

38-
// Sync external changes (e.g. emitter dropdown) → YAML model when in yaml mode.
39-
// Skips when the change originated from the YAML editor itself.
40-
useEffect(() => {
41-
if (mode !== "yaml") return;
42-
if (changeFromYamlRef.current) {
43-
changeFromYamlRef.current = false;
44-
return;
45-
}
46-
const yaml = compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
47-
const current = yamlModel.getValue();
48-
if (current !== yaml) {
49-
yamlModel.setValue(yaml);
50-
}
51-
}, [selectedEmitter, compilerOptions, mode, yamlModel]);
52-
53-
// Debounced YAML → CompilerOptions parsing
54-
const parseAndSync = useMemo(
42+
// Debounced YAML → state propagation. The raw text is the source of truth so it is
43+
// stored verbatim (comments, ordering and unknown fields are all preserved).
44+
const propagateChange = useMemo(
5545
() =>
5646
debounce((content: string) => {
57-
const parsed = parseTspConfigYaml(content);
58-
if (!parsed) return; // Invalid YAML — don't touch state
59-
changeFromYamlRef.current = true;
60-
if (parsed.selectedEmitter && parsed.selectedEmitter !== selectedEmitter) {
61-
onSelectedEmitterChange(parsed.selectedEmitter);
62-
}
63-
onCompilerOptionsChange(parsed.compilerOptions);
47+
onTspconfigChange(content);
6448
}, 200),
65-
[selectedEmitter, onCompilerOptionsChange, onSelectedEmitterChange],
49+
[onTspconfigChange],
6650
);
6751

6852
// Listen for YAML model changes
6953
useEffect(() => {
7054
const disposable = yamlModel.onDidChangeContent(() => {
71-
parseAndSync(yamlModel.getValue());
55+
changeFromYamlRef.current = true;
56+
propagateChange(yamlModel.getValue());
7257
});
7358
return () => {
74-
parseAndSync.clear();
59+
propagateChange.clear();
7560
disposable.dispose();
7661
};
77-
}, [yamlModel, parseAndSync]);
62+
}, [yamlModel, propagateChange]);
7863

79-
// Populate YAML model when switching to yaml mode
80-
const handleModeChange = useCallback<SelectTabEventHandler>(
81-
(_, data) => {
82-
const newMode = data.value as ConfigMode;
83-
if (newMode === "yaml") {
84-
const yaml = compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
85-
yamlModel.setValue(yaml);
86-
}
87-
setMode(newMode);
88-
},
89-
[selectedEmitter, compilerOptions, yamlModel],
90-
);
64+
// Sync state → YAML model (initial load, visual-form edits, samples, external changes).
65+
// Skips when the change originated from the YAML editor to avoid reverting live edits.
66+
useEffect(() => {
67+
if (changeFromYamlRef.current) {
68+
changeFromYamlRef.current = false;
69+
return;
70+
}
71+
if (yamlModel.getValue() !== tspconfig) {
72+
yamlModel.setValue(tspconfig);
73+
}
74+
}, [tspconfig, yamlModel]);
75+
76+
const handleModeChange = useCallback<SelectTabEventHandler>((_, data) => {
77+
setMode(data.value as ConfigMode);
78+
}, []);
9179

9280
return (
9381
<div className={style["config-panel"]}>

packages/playground/src/react/editor-panel/editor-panel.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ export interface EditorPanelProps {
3737

3838
selectedEmitter: string;
3939
compilerOptions: CompilerOptions;
40+
tspconfig: string;
4041
onCompilerOptionsChange: (options: CompilerOptions) => void;
4142
onSelectedEmitterChange: (emitter: string) => void;
43+
onTspconfigChange: (tspconfig: string) => void;
4244

4345
/** Toolbar content rendered above the editor area */
4446
commandBar?: ReactNode;
@@ -52,8 +54,10 @@ export const EditorPanel: FunctionComponent<EditorPanelProps> = ({
5254
onMount,
5355
selectedEmitter,
5456
compilerOptions,
57+
tspconfig,
5558
onCompilerOptionsChange,
5659
onSelectedEmitterChange,
60+
onTspconfigChange,
5761
commandBar,
5862
}) => {
5963
const [selectedTab, setSelectedTab] = useState<EditorPanelTab>("tsp");
@@ -92,8 +96,10 @@ export const EditorPanel: FunctionComponent<EditorPanelProps> = ({
9296
host={host}
9397
selectedEmitter={selectedEmitter}
9498
compilerOptions={compilerOptions}
99+
tspconfig={tspconfig}
95100
onCompilerOptionsChange={onCompilerOptionsChange}
96101
onSelectedEmitterChange={onSelectedEmitterChange}
102+
onTspconfigChange={onTspconfigChange}
97103
editorOptions={editorOptions}
98104
/>
99105
)}

packages/playground/src/react/editor-panel/tspconfig-utils.ts

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { CompilerOptions, LinterRuleSet } from "@typespec/compiler";
2-
import { parse, stringify } from "yaml";
2+
import { isMap, parse, parseDocument, stringify } from "yaml";
33

44
export interface TspConfig {
55
emit?: string[];
@@ -11,6 +11,15 @@ export interface TspConfig {
1111
"output-dir"?: string;
1212
}
1313

14+
function hasLinterRules(linterRuleSet: LinterRuleSet | undefined): boolean {
15+
if (!linterRuleSet) return false;
16+
return Boolean(
17+
(linterRuleSet.extends && linterRuleSet.extends.length > 0) ||
18+
(linterRuleSet.enable && Object.keys(linterRuleSet.enable).length > 0) ||
19+
(linterRuleSet.disable && Object.keys(linterRuleSet.disable).length > 0),
20+
);
21+
}
22+
1423
/**
1524
* Serialize the current playground state (emitter + compiler options) to tspconfig.yaml content.
1625
*/
@@ -28,13 +37,56 @@ export function compilerOptionsToTspConfig(
2837
config.options = compilerOptions.options;
2938
}
3039

31-
if (compilerOptions.linterRuleSet) {
40+
if (hasLinterRules(compilerOptions.linterRuleSet)) {
3241
config.linter = compilerOptions.linterRuleSet;
3342
}
3443

3544
return stringify(config, { indent: 2 }) || "";
3645
}
3746

47+
/**
48+
* Update an existing tspconfig.yaml with structured changes coming from the visual form
49+
* (emitter + compiler options) while preserving comments and any other fields the form
50+
* doesn't manage (e.g. `output-dir`, `warn-as-error`, `imports`).
51+
*/
52+
export function updateTspConfigYaml(
53+
existingYaml: string,
54+
selectedEmitter: string,
55+
compilerOptions: CompilerOptions,
56+
): string {
57+
let doc;
58+
try {
59+
doc = parseDocument(existingYaml ?? "");
60+
} catch {
61+
return compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
62+
}
63+
64+
// If the existing content isn't a clean mapping (empty/invalid), rebuild from scratch.
65+
if (doc.errors.length > 0 || !isMap(doc.contents)) {
66+
return compilerOptionsToTspConfig(selectedEmitter, compilerOptions);
67+
}
68+
69+
if (selectedEmitter) {
70+
doc.setIn(["emit"], [selectedEmitter]);
71+
} else {
72+
doc.deleteIn(["emit"]);
73+
}
74+
75+
if (compilerOptions.options && Object.keys(compilerOptions.options).length > 0) {
76+
doc.setIn(["options"], compilerOptions.options);
77+
} else {
78+
doc.deleteIn(["options"]);
79+
}
80+
81+
if (hasLinterRules(compilerOptions.linterRuleSet)) {
82+
doc.setIn(["linter"], compilerOptions.linterRuleSet);
83+
} else {
84+
doc.deleteIn(["linter"]);
85+
}
86+
87+
return doc.toString();
88+
}
89+
3890
export interface ParsedTspConfig {
3991
selectedEmitter?: string;
4092
compilerOptions: CompilerOptions;

packages/playground/src/react/hooks/use-compilation.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { CompilerOptions } from "@typespec/compiler";
21
import { $ } from "@typespec/compiler/typekit";
32
import { MarkerSeverity, MarkerTag, editor } from "monaco-editor";
43
import { useCallback, useEffect, useRef, useState } from "react";
@@ -11,7 +10,8 @@ import type { CompilationState } from "../types.js";
1110
export interface UseCompilationOptions {
1211
host: BrowserHost;
1312
selectedEmitter: string;
14-
compilerOptions: CompilerOptions;
13+
/** Raw tspconfig.yaml content used to configure the compilation. */
14+
tspconfig: string;
1515
typespecModel: editor.ITextModel;
1616
}
1717

@@ -25,7 +25,7 @@ export interface UseCompilationResult {
2525
export function useCompilation({
2626
host,
2727
selectedEmitter,
28-
compilerOptions,
28+
tspconfig,
2929
typespecModel,
3030
}: UseCompilationOptions): UseCompilationResult {
3131
const [compilationState, setCompilationState] = useState<CompilationState | undefined>(undefined);
@@ -57,7 +57,7 @@ export function useCompilation({
5757
setIsCompiling(true);
5858
let state: CompilationState;
5959
try {
60-
state = await compile(host, currentContent, selectedEmitter, compilerOptions);
60+
state = await compile(host, currentContent, selectedEmitter, tspconfig);
6161
} catch (error) {
6262
// eslint-disable-next-line no-console
6363
console.error("Compilation failed", error);
@@ -121,7 +121,7 @@ export function useCompilation({
121121
pendingRecompileRef.current = false;
122122
void doCompileRef.current();
123123
}
124-
}, [host, selectedEmitter, compilerOptions, typespecModel]);
124+
}, [host, selectedEmitter, tspconfig, typespecModel]);
125125

126126
useEffect(() => {
127127
doCompileRef.current = doCompile;

packages/playground/src/react/hooks/use-editor-actions.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface UseEditorActionsOptions {
1515
editorRef: RefObject<editor.IStandaloneCodeEditor | undefined>;
1616
selectedEmitter: string;
1717
compilerOptions: CompilerOptions;
18+
tspconfig: string;
1819
selectedSampleName: string;
1920
isSampleUntouched: boolean;
2021
selectedViewer?: string;
@@ -35,6 +36,7 @@ export function useEditorActions({
3536
editorRef,
3637
selectedEmitter,
3738
compilerOptions,
39+
tspconfig,
3840
selectedSampleName,
3941
isSampleUntouched,
4042
selectedViewer,
@@ -49,6 +51,7 @@ export function useEditorActions({
4951
content: currentContent,
5052
emitter: selectedEmitter,
5153
compilerOptions,
54+
tspconfig,
5255
sampleName: isSampleUntouched ? selectedSampleName : undefined,
5356
selectedViewer,
5457
viewerState,
@@ -59,6 +62,7 @@ export function useEditorActions({
5962
onSave,
6063
selectedEmitter,
6164
compilerOptions,
65+
tspconfig,
6266
selectedSampleName,
6367
isSampleUntouched,
6468
selectedViewer,

0 commit comments

Comments
 (0)