Skip to content

Commit

Permalink
Add a new init template to setup a library (#2766)
Browse files Browse the repository at this point in the history
Add a new library template with:
- `@alernateName` decorator with some custom diagnostics and tests for
it.
- a linter rule and 2 rulesets(`recommended` and `all`)
  • Loading branch information
timotheeguerin authored Jan 4, 2024
1 parent 53aa383 commit 55e232d
Show file tree
Hide file tree
Showing 45 changed files with 702 additions and 35 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ packages/website/playground-versions.json

# Auto generated built-in template list
packages/compiler/templates/scaffolding.json
packages/compiler/templates/__snapshots__/

#.tsp init template
eng/feeds/
1 change: 1 addition & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"**/CHANGELOG.json": true,
"docs/spec.html": true,
"**/node_modules/**": true,
"packages/compiler/templates/__snapshots__/**": true,
"packages/website/versioned_docs/**": true,
"packages/samples/scratch/**": false // Those files are in gitignore but we still want to search for them
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"changes": [
{
"packageName": "@typespec/compiler",
"comment": "Add a new `tsp init` template for setting up a library",
"type": "none"
}
],
"packageName": "@typespec/compiler"
}
3 changes: 3 additions & 0 deletions docs/extending-typespec/basics.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ You will need both node and npm installed. Additionally, if you intend to develo
Available templates:

```bash
# Create a TypeSpec library(Decorators & Linters) with TypeScript enabled.
tsp init --template library-ts

# Create a TypeSpec emitter with TypeScript enabled.
tsp init --template emitter-ts
```
Expand Down
12 changes: 11 additions & 1 deletion packages/compiler/.scripts/build-init-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,22 @@ const builtInTemplates: Record<string, InitTemplate> = {
emit: ["@typespec/openapi3"],
},
},
"library-ts": {
title: "TypeSpec Library (With TypeScript)",
description: "Create a new package to add decorators or linters to typespec.",
compilerVersion: minCompilerVersion,
libraries: [],
files: [
{ destination: "main.tsp", skipGeneration: true },
{ destination: "tspconfig.yaml", skipGeneration: true },
...(await localDir("library-ts")),
],
},
"emitter-ts": {
title: "TypeSpec Emitter (With TypeScript)",
description: "Create a new package that will be emitting typespec",
compilerVersion: minCompilerVersion,
libraries: [],
config: undefined,
files: [
{ destination: "main.tsp", skipGeneration: true },
{ destination: "tspconfig.yaml", skipGeneration: true },
Expand Down
2 changes: 1 addition & 1 deletion packages/compiler/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export {
setTypeSpecNamespace,
} from "./library.js";
export * from "./module-resolver.js";
export * from "./node-host.js";
export { NodeHost } from "./node-host.js";
export * from "./options.js";
export * from "./parser.js";
export * from "./path-utils.js";
Expand Down
4 changes: 2 additions & 2 deletions packages/compiler/src/core/node-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { joinPaths } from "./path-utils.js";
import { CompilerHost, RmOptions } from "./types.js";
import { findProjectRoot, getSourceFileKindFromExt } from "./util.js";

const root = (await findProjectRoot(stat, fileURLToPath(import.meta.url)))!;
export const CompilerPackageRoot = (await findProjectRoot(stat, fileURLToPath(import.meta.url)))!;

/**
* Implementation of the @see CompilerHost using the real file system.
Expand All @@ -23,7 +23,7 @@ export const NodeHost: CompilerHost = {
writeFile: (path: string, content: string) => writeFile(path, content, { encoding: "utf-8" }),
readDir: (path: string) => readdir(path),
rm: (path: string, options: RmOptions) => rm(path, options),
getExecutionRoot: () => root,
getExecutionRoot: () => CompilerPackageRoot,
getJsImport: (path: string) => import(pathToFileURL(path).href),
getLibDirs() {
const rootDir = this.getExecutionRoot();
Expand Down
8 changes: 2 additions & 6 deletions packages/compiler/src/init/core-templates.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { readFile } from "fs/promises";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { CompilerPackageRoot } from "../core/node-host.js";
import { resolvePath } from "../core/path-utils.js";

export const templatesDir = resolvePath(
dirname(fileURLToPath(import.meta.url)),
"../../../templates"
);
export const templatesDir = resolvePath(CompilerPackageRoot, "templates");

const content = JSON.parse(await readFile(resolvePath(templatesDir, "scaffolding.json"), "utf-8"));

Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import {
createTestLibrary,
findTestPackageRoot,
TypeSpecTestLibrary,
} from "@typespec/compiler/testing";
import { resolvePath } from "@typespec/compiler";
import { createTestLibrary, TypeSpecTestLibrary } from "@typespec/compiler/testing";
import { fileURLToPath } from "url";

export const TestLibrary: TypeSpecTestLibrary = createTestLibrary({
export const EmitterTsTestLibrary: TypeSpecTestLibrary = createTestLibrary({
name: "emitter-ts",
packageRoot: await findTestPackageRoot(import.meta.url),
packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../../"),
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import {
createTestWrapper,
expectDiagnosticEmpty,
} from "@typespec/compiler/testing";
import { TestLibrary } from "../src/testing/index.js";
import { EmitterTsTestLibrary } from "../src/testing/index.js";

export async function createEmitterTsTestHost() {
return createTestHost({
libraries: [TestLibrary],
libraries: [EmitterTsTestLibrary],
});
}

Expand Down
14 changes: 14 additions & 0 deletions packages/compiler/templates/__snapshots__/library-ts/.eslintrc.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
root: true
env:
es2021: true
node: true
extends:
- eslint:recommended
- plugin:@typescript-eslint/recommended
parser: "@typescript-eslint/parser"
parserOptions:
ecmaVersion: latest
sourceType: module
plugins:
- "@typescript-eslint"
rules: {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import "../dist/src/decorators.js";

using TypeSpec.Reflection;

namespace LibraryTs;

/**
* __Example Decorator__
* Provide an alternate name for an operation.
* @param name The alternate name.
*/
extern dec alternateName(target: Operation, name: valueof string);
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import "./decorators.tsp";
40 changes: 40 additions & 0 deletions packages/compiler/templates/__snapshots__/library-ts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "library-ts",
"version": "0.1.0",
"type": "module",
"main": "dist/src/index.js",
"tspMain": "lib/main.tsp",
"exports": {
".": {
"types": "./dist/src/index.d.ts",
"default": "./dist/src/index.js"
},
"./testing": {
"types": "./dist/src/testing/index.d.ts",
"default": "./dist/src/testing/index.js"
}
},
"dependencies": {
"@typespec/compiler": "latest"
},
"devDependencies": {
"@types/node": "latest",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@typespec/library-linter": "latest",
"eslint": "^8.45.0",
"prettier": "^3.0.3",
"typescript": "^5.3.3"
},
"scripts": {
"build": "tsc && npm run build:tsp",
"watch": "tsc --watch",
"build:tsp": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit",
"test": "node --test ./dist/test/",
"lint": "eslint src/ test/ --report-unused-disable-directives --max-warnings=0",
"lint:fix": "eslint . --report-unused-disable-directives --fix",
"format": "prettier . --write",
"format:check": "prettier --check ."
},
"private": true
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
trailingComma: "all"
printWidth: 120
quoteProps: "consistent"
endOfLine: lf
arrowParens: always
plugins:
- "./node_modules/@typespec/prettier-plugin-typespec/dist/index.js"
overrides: [{ "files": "*.tsp", "options": { "parser": "typespec" } }]
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DecoratorContext, Operation, Program } from "@typespec/compiler";
import { StateKeys, reportDiagnostic } from "./lib.js";

export const namespace = "LibraryTs";

/**
* __Example implementation of the `@alternateName` decorator.__
*
* @param context Decorator context.
* @param target Decorator target. Must be an operation.
* @param name Alternate name.
*/
export function $alternateName(context: DecoratorContext, target: Operation, name: string) {
if (name === "banned") {
reportDiagnostic(context.program, {
code: "banned-alternate-name",
target: context.getArgumentTarget(0)!,
format: { name },
});
}
context.program.stateMap(StateKeys.alternateName).set(target, name);
}

/**
* __Example accessor for the `@alternateName` decorator.__
*
* @param program TypeSpec program.
* @param target Decorator target. Must be an operation.
* @returns Altenate name if provided on the given operation or undefined
*/
export function getAlternateName(program: Program, target: Operation): string | undefined {
return program.stateMap(StateKeys.alternateName).get(target);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { getAlternateName } from "./decorators.js";
export { $lib } from "./lib.js";
20 changes: 20 additions & 0 deletions packages/compiler/templates/__snapshots__/library-ts/src/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createTypeSpecLibrary, paramMessage } from "@typespec/compiler";

export const $lib = createTypeSpecLibrary({
name: "library-ts",
// Define diagnostics for the library. This will provide a typed API to report diagnostic as well as a auto doc generation.
diagnostics: {
"banned-alternate-name": {
severity: "error",
messages: {
default: paramMessage`Banned alternate name "${"name"}".`,
},
},
},
// Defined state keys for storing metadata in decorator.
state: {
alternateName: { description: "alternateName" },
},
});

export const { reportDiagnostic, createDiagnostic, stateKeys: StateKeys } = $lib;
14 changes: 14 additions & 0 deletions packages/compiler/templates/__snapshots__/library-ts/src/linter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { defineLinter } from "@typespec/compiler";
import { noInterfaceRule } from "./rules/no-interfaces.rule.js";

export const $linter = defineLinter({
rules: [noInterfaceRule],
ruleSets: {
recommended: {
enable: { [`library-ts/${noInterfaceRule.name}`]: true },
},
all: {
enable: { [`library-ts/${noInterfaceRule.name}`]: true },
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { createRule } from "@typespec/compiler";

export const noInterfaceRule = createRule({
name: "no-interface",
severity: "warning",
description: "Make sure interface are not used.",
messages: {
default: "Interface shouldn't be used with this library. Keep operations at the root.",
},
create: (context) => {
return {
interface: (iface) => {
context.reportDiagnostic({
target: iface,
});
},
};
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { resolvePath } from "@typespec/compiler";
import { createTestLibrary, TypeSpecTestLibrary } from "@typespec/compiler/testing";
import { fileURLToPath } from "url";

export const LibraryTsTestLibrary: TypeSpecTestLibrary = createTestLibrary({
name: "library-ts",
packageRoot: resolvePath(fileURLToPath(import.meta.url), "../../../../"),
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { strictEqual } from "node:assert";
import { describe, it, beforeEach } from "node:test";
import { Operation } from "@typespec/compiler";
import { BasicTestRunner, expectDiagnostics, extractCursor } from "@typespec/compiler/testing";
import { getAlternateName } from "../src/decorators.js";
import { createLibraryTsTestRunner } from "./test-host.js";

describe("decorators", () => {
let runner: BasicTestRunner;

beforeEach(async () => {
runner = await createLibraryTsTestRunner();
})

describe("@alternateName", () => {
it("set alternate name on operation", async () => {
const { test } = (await runner.compile(
`@alternateName("bar") @test op test(): void;`
)) as { test: Operation };
strictEqual(getAlternateName(runner.program, test), "bar");
});

it("emit diagnostic if not used on an operation", async () => {
const diagnostics = await runner.diagnose(
`@alternateName("bar") model Test {}`
);
expectDiagnostics(diagnostics, {
severity: "error",
code: "decorator-wrong-target",
message: "Cannot apply @alternateName decorator to Test since it is not assignable to Operation"
})
});


it("emit diagnostic if using banned name", async () => {
const {pos, source} = extractCursor(`@alternateName(┆"banned") op test(): void;`)
const diagnostics = await runner.diagnose(
source
);
expectDiagnostics(diagnostics, {
severity: "error",
code: "library-ts/banned-alternate-name",
message: `Banned alternate name "banned".`,
pos: pos + runner.autoCodeOffset
})
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {
LinterRuleTester,
createLinterRuleTester,
createTestRunner,
} from "@typespec/compiler/testing";
import { beforeEach, describe, it } from "node:test";
import { noInterfaceRule } from "../../src/rules/no-interfaces.rule.js";

describe("noInterfaceRule", () => {
let ruleTester: LinterRuleTester;

beforeEach(async () => {
const runner = await createTestRunner();
ruleTester = createLinterRuleTester(runner, noInterfaceRule, "library-ts");
});

describe("models", () => {
it("emit diagnostics if using interfaces", async () => {
await ruleTester.expect(`interface Test {}`).toEmitDiagnostics({
code: "library-ts/no-interface",
message: "Interface shouldn't be used with this library. Keep operations at the root.",
});
});

it("should be valid if operation is at the root", async () => {
await ruleTester.expect(`op test(): void;`).toBeValid();
});
});
});
Loading

0 comments on commit 55e232d

Please sign in to comment.