Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@typespec/compiler"
---

Add `project` field to tspconfig.yaml for defining project boundaries and explicit entrypoint declaration
27 changes: 25 additions & 2 deletions packages/compiler/src/config/config-loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { getLocationInYamlScript } from "../yaml/index.js";
import { parseYaml } from "../yaml/parser.js";
import { YamlScript } from "../yaml/types.js";
import { TypeSpecConfigJsonSchema } from "./config-schema.js";
import { TypeSpecConfig, TypeSpecRawConfig } from "./types.js";
import {
TypeSpecConfig,
TypeSpecProjectConfig,
TypeSpecRawConfig,
TypeSpecRawProjectConfig,
} from "./types.js";

export const TypeSpecConfigFilename = "tspconfig.yaml";

Expand Down Expand Up @@ -180,13 +185,15 @@ async function loadConfigFile(

const emit = data.emit;
const options = data.options;
const configDir = getDirectoryPath(filename);

return omitUndefined({
projectRoot: getDirectoryPath(filename),
projectRoot: configDir,
file: yamlScript,
filename,
diagnostics,
extends: data.extends,
project: resolveProjectConfig(data.project, configDir),
environmentVariables: data["environment-variables"],
parameters: data.parameters,
outputDir: data["output-dir"] ?? "{cwd}/tsp-output",
Expand All @@ -199,6 +206,22 @@ async function loadConfigFile(
});
}

/**
* Resolve the raw project config into a normalized project config with absolute paths.
*/
function resolveProjectConfig(
raw: TypeSpecRawProjectConfig | undefined,
configDir: string,
): TypeSpecProjectConfig | undefined {
if (raw === undefined) {
return undefined;
}
const entrypoint = raw === true ? "main.tsp" : (raw.entrypoint ?? "main.tsp");
return {
entrypoint: resolvePath(configDir, entrypoint),
};
}

export function validateConfigPathsAbsolute(config: TypeSpecConfig): readonly Diagnostic[] {
const diagnostics: Diagnostic[] = [];

Expand Down
16 changes: 16 additions & 0 deletions packages/compiler/src/config/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ export const TypeSpecConfigJsonSchema: JSONSchemaType<TypeSpecRawConfig> = {
type: "string",
nullable: true,
},
project: {
oneOf: [
{ type: "boolean", const: true },
{
type: "object",
properties: {
entrypoint: {
type: "string",
nullable: true,
},
},
additionalProperties: false,
required: [],
},
],
} as any, // AJV typing doesn't handle oneOf with mixed types well
"environment-variables": {
type: "object",
nullable: true,
Expand Down
38 changes: 38 additions & 0 deletions packages/compiler/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
import type { Diagnostic, RuleRef } from "../core/types.js";
import type { YamlScript } from "../yaml/types.js";

/**
* Resolved project configuration for a project boundary.
*/
export interface TypeSpecProjectConfig {
/**
* Resolved absolute path to the entrypoint file.
*/
entrypoint: string;
}

/**
* Raw project configuration as provided in tspconfig.yaml.
* Can be `true` (shorthand for all defaults) or an object with explicit settings.
*/
export type TypeSpecRawProjectConfig =
| true
| {
/**
* Main TypeSpec file for this project, relative to config directory.
* @default "main.tsp"
*/
entrypoint?: string;
};

/**
* Represent the normalized user configuration.
*/
Expand Down Expand Up @@ -28,6 +52,13 @@ export interface TypeSpecConfig {
*/
extends?: string;

/**
* Resolved project configuration.
* When present, this config defines a project boundary and the directory
* containing this file is the project root.
*/
project?: TypeSpecProjectConfig;

/**
* Environment variables configuration
*/
Expand Down Expand Up @@ -76,6 +107,13 @@ export interface TypeSpecConfig {
*/
export interface TypeSpecRawConfig {
extends?: string;

/**
* Marks this configuration as a project boundary.
* When present, the directory containing this file is the project root.
*/
project?: TypeSpecRawProjectConfig;

"environment-variables"?: Record<string, ConfigEnvironmentVariable>;
parameters?: Record<string, ConfigParameter>;

Expand Down
7 changes: 7 additions & 0 deletions packages/compiler/src/core/entrypoint-resolution.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { loadTypeSpecConfigForPath } from "../config/config-loader.js";
import { doIO, loadFile } from "../utils/io.js";
import { resolveTspMain } from "../utils/misc.js";
import { DiagnosticHandler } from "./diagnostics.js";
Expand Down Expand Up @@ -32,6 +33,12 @@ export async function resolveTypeSpecEntrypointForDir(
dir: string,
reportDiagnostic: DiagnosticHandler,
): Promise<string> {
// Check for project tspconfig.yaml first — highest priority
const config = await loadTypeSpecConfigForPath(host, dir, false, false);
if (config.project) {
return config.project.entrypoint;
}

const pkgJsonPath = resolvePath(dir, "package.json");
const [pkg] = await loadFile(host, pkgJsonPath, JSON.parse, reportDiagnostic, {
allowFileNotFound: true,
Expand Down
20 changes: 20 additions & 0 deletions packages/compiler/src/server/entrypoint-resolver.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TypeSpecConfigFilename, loadTypeSpecConfigFile } from "../config/config-loader.js";
import { formatDiagnostic } from "../core/logger/console-sink.js";
import { getDirectoryPath, joinPaths } from "../core/path-utils.js";
import { SystemHost, Diagnostic as TypeSpecDiagnostic } from "../core/types.js";
Expand Down Expand Up @@ -27,6 +28,25 @@ export async function resolveEntrypointFile(
}

while (true) {
// Check for project tspconfig.yaml first — highest priority
const tspConfigPath = joinPaths(dir, TypeSpecConfigFilename);
const tspConfigStat = await doIO(
() => host.stat(tspConfigPath),
tspConfigPath,
logMainFileSearchDiagnostic,
options,
);
if (tspConfigStat?.isFile()) {
const config = await loadTypeSpecConfigFile(host, tspConfigPath);
if (config.diagnostics.length === 0 && config.project) {
logDebug({
level: "debug",
message: `project entrypoint resolved from tspconfig.yaml (${tspConfigPath}) as ${config.project.entrypoint}`,
});
return config.project.entrypoint;
}
}

let pkg: any;
const pkgPath = joinPaths(dir, "package.json");
const cached = await fileSystemCache?.get(pkgPath);
Expand Down
70 changes: 70 additions & 0 deletions packages/compiler/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,54 @@ describe("compiler: config file loading", () => {
});
});

describe("project config", () => {
const loadFullConfig = async (path: string, lookup: boolean = true) => {
const fullPath = join(scenarioRoot, path);
return loadTypeSpecConfigForPath(NodeHost, fullPath, false, lookup);
};

it("loads project: true shorthand", async () => {
const config = await loadFullConfig("project-basic", false);
strictEqual(config.diagnostics.length, 0);
strictEqual(config.project !== undefined, true);
strictEqual(config.project!.entrypoint.endsWith("main.tsp"), true);
deepStrictEqual(config.emit, ["openapi"]);
});

it("loads project with explicit entrypoint", async () => {
const config = await loadFullConfig("project-entrypoint", false);
strictEqual(config.diagnostics.length, 0);
strictEqual(config.project !== undefined, true);
strictEqual(config.project!.entrypoint.endsWith("src/service.tsp"), true);
deepStrictEqual(config.emit, ["openapi"]);
});

it("resolves entrypoint as absolute path relative to config directory", async () => {
const config = await loadFullConfig("project-entrypoint", false);
const expectedSuffix = join("project-entrypoint", "src", "service.tsp");
strictEqual(config.project!.entrypoint.endsWith(expectedSuffix), true);
});

it("config without project field has no project property", async () => {
const config = await loadFullConfig("simple", false);
strictEqual(config.project, undefined);
});

it("loads nested project configs independently", async () => {
const rootConfig = await loadFullConfig("project-nested", false);
const nestedConfig = await loadFullConfig("project-nested/services/orders", false);

strictEqual(rootConfig.project !== undefined, true);
strictEqual(nestedConfig.project !== undefined, true);

strictEqual(rootConfig.project!.entrypoint.endsWith("project-nested/main.tsp"), true);
strictEqual(nestedConfig.project!.entrypoint.endsWith("services/orders/main.tsp"), true);

deepStrictEqual(rootConfig.emit, ["openapi"]);
deepStrictEqual(nestedConfig.emit, ["@typespec/http-client-csharp"]);
});
});

describe("validation", () => {
const validator = createJSONSchemaValidator(TypeSpecConfigJsonSchema);
const file = createSourceFile("<content>", "<path>");
Expand Down Expand Up @@ -168,5 +216,27 @@ describe("compiler: config file loading", () => {
it("succeeds if config is valid", () => {
deepStrictEqual(validate({ options: { openapi: {} } }), []);
});

it("succeeds with project: true", () => {
deepStrictEqual(validate({ project: true }), []);
});

it("succeeds with project object with entrypoint", () => {
deepStrictEqual(validate({ project: { entrypoint: "src/main.tsp" } }), []);
});

it("succeeds with project object without entrypoint", () => {
deepStrictEqual(validate({ project: {} }), []);
});

it("fails with project: false", () => {
const diagnostics = validate({ project: false } as any);
strictEqual(diagnostics.length > 0, true);
});

it("fails with project containing unknown properties", () => {
const diagnostics = validate({ project: { unknown: "value" } } as any);
strictEqual(diagnostics.length > 0, true);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project: true
emit:
- openapi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project:
entrypoint: src/service.tsp
emit:
- openapi
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
project:
entrypoint: main.tsp
emit:
- "@typespec/http-client-csharp"
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
project: true
emit:
- openapi
2 changes: 2 additions & 0 deletions packages/compiler/test/server/completion.tspconfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const rootOptions = [
"emit",
"options",
"linter",
"project",
];

describe("Test completion items for root options", () => {
Expand Down Expand Up @@ -443,6 +444,7 @@ describe("Test completion items for extends", () => {
"options",
"output-dir",
"parameters",
"project",
"trace",
"warn-as-error",
],
Expand Down
Loading
Loading