Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Add format: absolute-path to options validator #2708

Merged
merged 14 commits into from
Dec 5, 2023
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/compiler",
"comment": "Emitter API: Added `absolute-path` as a known format for emitter options which will validate the value passed by the user resolve to an absolute path.",
"type": "none"
}
],
"packageName": "@typespec/compiler"
}
18 changes: 18 additions & 0 deletions docs/extending-typespec/emitters-basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,24 @@ export async function $onEmit(context: EmitContext<EmitterOptions>) {
}
```

### Emitter options known format:

### `absolute-path`

Specify that the value for this option should resolve to an absolute path. e.g. `"{project-root}/dir"`.

:::important
It is recommended that all options that involve path use this. Using relative path can be confusing for users on as it is not clear what the relative path is relative to. And more importantly relative path if not careful are resolved relative to the `cwd` in node file system which result in spec only compiling from the the project root.
:::

Example:

```js
{
"asset-dir": { type: "string", format: "absolute-path", nullable: true },
}
```

### Configuration options convention

- Name options `kebab-case`. So it can be inline with the rest of the cli
Expand Down
3 changes: 2 additions & 1 deletion packages/compiler/src/config/config-to-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ export function resolveOptionsFromConfig(config: TypeSpecConfig, options: Config

const resolvedOptions: CompilerOptions = omitUndefined({
outputDir: expandedConfig.outputDir,
config: config.filename,
configPath: config.filename,
config: config,
additionalImports: expandedConfig["imports"],
warningAsError: expandedConfig.warnAsError,
trace: expandedConfig.trace,
Expand Down
9 changes: 6 additions & 3 deletions packages/compiler/src/core/options.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EmitterOptions } from "../config/types.js";
import { EmitterOptions, TypeSpecConfig } from "../config/types.js";
import { LinterRuleSet, ParseOptions } from "./types.js";

export interface CompilerOptions {
Expand All @@ -11,9 +11,9 @@ export interface CompilerOptions {
outputDir?: string;

/**
* Path to config YAML file or folder in which to search for default tspconfig.yaml file.
* Path to config YAML file used, this is also where the project root should be.
*/
config?: string;
configPath?: string;
timotheeguerin marked this conversation as resolved.
Show resolved Hide resolved

/**
* @deprecated use outputDir.
Expand Down Expand Up @@ -63,4 +63,7 @@ export interface CompilerOptions {

/** Ruleset to enable for linting. */
linterRuleSet?: LinterRuleSet;

/** @internal */
config?: TypeSpecConfig;
}
10 changes: 8 additions & 2 deletions packages/compiler/src/core/program.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ export async function compile(
getGlobalNamespaceType,
resolveTypeReference,
getSourceFileLocationContext,
projectRoot: getDirectoryPath(options.config ?? resolvedMain ?? ""),
projectRoot: getDirectoryPath(options.configPath ?? resolvedMain ?? ""),
};

trace("compiler.options", JSON.stringify(options, null, 2));
Expand Down Expand Up @@ -753,7 +753,13 @@ export async function compile(
if (libDefinition?.emitter?.options) {
const diagnostics = libDefinition?.emitterOptionValidator?.validate(
emitterOptions,
NoTarget
options.config?.file
? {
kind: "path-target",
path: ["options", emitterNameOrPath],
script: options.config.file,
}
: NoTarget
);
if (diagnostics && diagnostics.length > 0) {
program.reportDiagnostics(diagnostics);
Expand Down
83 changes: 68 additions & 15 deletions packages/compiler/src/core/schema-validator.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import Ajv, { DefinedError, Options } from "ajv";
import Ajv, { ErrorObject, Options } from "ajv";
import { getLocationInYamlScript } from "../yaml/diagnostics.js";
import { YamlScript } from "../yaml/types.js";
import { YamlPathTarget, YamlScript } from "../yaml/types.js";
import { compilerAssert } from "./diagnostics.js";
import { Diagnostic, JSONSchemaType, JSONSchemaValidator, NoTarget, SourceFile } from "./types.js";
import { createDiagnostic } from "./messages.js";
import { isPathAbsolute } from "./path-utils.js";
import {
Diagnostic,
DiagnosticTarget,
JSONSchemaType,
JSONSchemaValidator,
NoTarget,
SourceFile,
} from "./types.js";

export interface JSONSchemaValidatorOptions {
coerceTypes?: boolean;
Expand All @@ -13,17 +22,23 @@ export function createJSONSchemaValidator<T>(
schema: JSONSchemaType<T>,
options: JSONSchemaValidatorOptions = { strict: true }
): JSONSchemaValidator {
const ajv = new (Ajv as any)({
const ajv: import("ajv").default = new (Ajv as any)({
strict: options.strict,
coerceTypes: options.coerceTypes,
allErrors: true,
} satisfies Options);

ajv.addFormat("absolute-path", {
type: "string",
validate: (path) => {
return !path.startsWith(".") && isPathAbsolute(path);
},
});
return { validate };

function validate(
config: unknown,
target: YamlScript | SourceFile | typeof NoTarget
target: YamlScript | YamlPathTarget | SourceFile | typeof NoTarget
): Diagnostic[] {
const validate = ajv.compile(schema);
const valid = validate(config);
Expand All @@ -34,7 +49,7 @@ export function createJSONSchemaValidator<T>(

const diagnostics = [];
for (const error of validate.errors ?? []) {
const diagnostic = ajvErrorToDiagnostic(error, target);
const diagnostic = ajvErrorToDiagnostic(config, error, target);
diagnostics.push(diagnostic);
}

Expand All @@ -45,9 +60,19 @@ export function createJSONSchemaValidator<T>(
const IGNORED_AJV_PARAMS = new Set(["type", "errors"]);

function ajvErrorToDiagnostic(
error: DefinedError,
target: YamlScript | SourceFile | typeof NoTarget
obj: unknown,
error: ErrorObject<string, Record<string, any>, unknown>,
target: YamlScript | YamlPathTarget | SourceFile | typeof NoTarget
): Diagnostic {
const tspTarget = resolveTarget(error, target);
if (error.params.format === "absolute-path") {
return createDiagnostic({
code: "config-path-absolute",
format: { path: getErrorValue(obj, error) as any },
target: tspTarget,
});
}

const messageLines = [`Schema violation: ${error.message} (${error.instancePath || "/"})`];
for (const [name, value] of Object.entries(error.params).filter(
([name]) => !IGNORED_AJV_PARAMS.has(name)
Expand All @@ -61,16 +86,33 @@ function ajvErrorToDiagnostic(
code: "invalid-schema",
message,
severity: "error",
target:
target === NoTarget
? target
: "kind" in target
? getLocationInYamlScript(target, getErrorPath(error), "key")
: { file: target, pos: 0, end: 0 },
target: tspTarget,
};
}

function getErrorPath(error: DefinedError): string[] {
function resolveTarget(
error: ErrorObject<string, Record<string, any>, unknown>,
target: YamlScript | YamlPathTarget | SourceFile | typeof NoTarget
): DiagnosticTarget | typeof NoTarget {
if (target === NoTarget) {
return NoTarget;
}
if (!("kind" in target)) {
return { file: target, pos: 0, end: 0 };
}
switch (target.kind) {
case "yaml-script":
return getLocationInYamlScript(target, getErrorPath(error), "key");
case "path-target":
return getLocationInYamlScript(
target.script,
[...target.path, ...getErrorPath(error)],
"key"
);
}
}

function getErrorPath(error: ErrorObject<string, Record<string, any>, unknown>): string[] {
const instancePath = parseJsonPointer(error.instancePath);
switch (error.keyword) {
case "additionalProperties":
Expand All @@ -79,6 +121,17 @@ function getErrorPath(error: DefinedError): string[] {
return instancePath;
}
}
function getErrorValue(
obj: any,
error: ErrorObject<string, Record<string, any>, unknown>
): unknown {
const path = getErrorPath(error);
let current = obj;
for (const segment of path) {
current = current[segment];
}
return current;
}

/**
* Converts a json pointer into a array of reference tokens
Expand Down
11 changes: 9 additions & 2 deletions packages/compiler/src/core/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { JSONSchemaType as AjvJSONSchemaType } from "ajv";
import { TypeEmitter } from "../emitter-framework/type-emitter.js";
import { AssetEmitter } from "../emitter-framework/types.js";
import { YamlScript } from "../yaml/types.js";
import { YamlPathTarget, YamlScript } from "../yaml/types.js";
import { ModuleResolutionResult } from "./module-resolver.js";
import { Program } from "./program.js";

Expand Down Expand Up @@ -1963,6 +1963,9 @@ export type TypeOfDiagnostics<T extends DiagnosticMap<any>> = T extends Diagnost

export type JSONSchemaType<T> = AjvJSONSchemaType<T>;

/**
* @internal
*/
export interface JSONSchemaValidator {
/**
* Validate the configuration against its JSON Schema.
Expand All @@ -1971,7 +1974,10 @@ export interface JSONSchemaValidator {
* @param target Source file target to use for diagnostics.
* @returns Diagnostics produced by schema validation of the configuration.
*/
validate(config: unknown, target: YamlScript | SourceFile | typeof NoTarget): Diagnostic[];
validate(
config: unknown,
target: YamlScript | YamlPathTarget | SourceFile | typeof NoTarget
): Diagnostic[];
}

/** @deprecated Use TypeSpecLibraryDef */
Expand Down Expand Up @@ -2092,6 +2098,7 @@ export interface TypeSpecLibrary<
> extends TypeSpecLibraryDef<T, E> {
/**
* JSON Schema validator for emitter options
* @internal
*/
readonly emitterOptionValidator?: JSONSchemaValidator;

Expand Down
8 changes: 8 additions & 0 deletions packages/compiler/src/yaml/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,12 @@ export interface YamlScript {
readonly doc: Document.Parsed;
}

/**
* Represent the location of a value in a yaml script.
*/
export interface YamlPathTarget {
kind: "path-target";
script: YamlScript;
path: string[];
}
export type YamlDiagnosticTargetType = "value" | "key";
10 changes: 6 additions & 4 deletions packages/compiler/test/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { deepStrictEqual, strictEqual } from "assert";
import { deepStrictEqual, ok, strictEqual } from "assert";
import { stringify } from "yaml";
import { TypeSpecRawConfig } from "../src/config/types.js";
import { CompileCliArgs, getCompilerOptions } from "../src/core/cli/actions/compile/args.js";
Expand Down Expand Up @@ -31,7 +31,9 @@ describe("compiler: cli", () => {
env
);
expectDiagnosticEmpty(diagnostics);
return options;
ok(options, "Options should have been set.");
const { config, ...rest } = options;
return rest;
}

it("no args and config: return empty options with output-dir at {cwd}/tsp-output", async () => {
Expand Down Expand Up @@ -188,15 +190,15 @@ describe("compiler: cli", () => {

interface TestUnifiedOptions<
K extends keyof CompileCliArgs & keyof TypeSpecRawConfig,
T extends keyof CompilerOptions,
T extends Exclude<keyof CompilerOptions, "config">,
> {
default: CompileCliArgs[K];
set: { in: CompileCliArgs[K]; alt: CompileCliArgs[K]; expected: CompilerOptions[T] }[];
}

function testUnifiedOptions<
K extends keyof CompileCliArgs & keyof TypeSpecRawConfig,
T extends keyof CompilerOptions,
T extends Exclude<keyof CompilerOptions, "config">,
>(name: K, resolvedName: T, data: TestUnifiedOptions<K, T>) {
describe(name, () => {
it("default", async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,20 @@ describe("compiler: resolve compiler options", () => {
describe("specifying explicit config file", () => {
const resolveOptions = async (path: string) => {
const fullPath = resolvePath(scenarioRoot, path);
return await resolveCompilerOptions(NodeHost, {
const [{ config, ...options }, diagnostics] = await resolveCompilerOptions(NodeHost, {
cwd: normalizePath(process.cwd()),
entrypoint: fullPath, // not really used here
configPath: fullPath,
});
return [options, diagnostics] as const;
};

it("loads config at the given path", async () => {
const [options, diagnostics] = await resolveOptions("custom/myConfig.yaml");
expectDiagnosticEmpty(diagnostics);

deepStrictEqual(options, {
config: resolvePath(scenarioRoot, "custom/myConfig.yaml"),
configPath: resolvePath(scenarioRoot, "custom/myConfig.yaml"),
emit: ["openapi"],
options: {},
outputDir: tspOutputPath,
Expand Down
Loading