Skip to content

Commit

Permalink
refactor: Use TypeScript LanguageService (#518)
Browse files Browse the repository at this point in the history
Switching from just a `Program` to the `LanguageService` has several
benefits:

- Adding/changing source files is possible without having to create a
new program manually while keeping all unchanged files cached
- Host implementation is more lightweight
- A shared `LanguageService` can be used to improve performance when
linting multiple independent files/projects within the same
process/thread. This is currently only used internally by the tests, but
a new public API can be easily provided.
  • Loading branch information
matz3 authored Feb 7, 2025
1 parent c81c022 commit 85db5fa
Show file tree
Hide file tree
Showing 14 changed files with 403 additions and 187 deletions.
8 changes: 4 additions & 4 deletions .nycrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@
"**/*.d.ts"
],
"check-coverage": true,
"statements": 87,
"branches": 79,
"functions": 94,
"lines": 87,
"statements": 89,
"branches": 82,
"functions": 95,
"lines": 89,
"watermarks": {
"statements": [70, 90],
"branches": [70, 90],
Expand Down
6 changes: 3 additions & 3 deletions src/cli/base.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {Argv, ArgumentsCamelCase, CommandModule, MiddlewareFunction} from "yargs";
import {lintProject} from "../linter/linter.js";
import {Text} from "../formatter/text.js";
import {Json} from "../formatter/json.js";
import {Markdown} from "../formatter/markdown.js";
Expand All @@ -10,6 +9,7 @@ import chalk from "chalk";
import {isLogLevelEnabled} from "@ui5/logger";
import ConsoleWriter from "@ui5/logger/writers/Console";
import {getVersion} from "./version.js";
import {ui5lint} from "../index.js";

export interface LinterArg {
coverage: boolean;
Expand Down Expand Up @@ -152,13 +152,13 @@ async function handleLint(argv: ArgumentsCamelCase<LinterArg>) {

const reportCoverage = !!(process.env.UI5LINT_COVERAGE_REPORT ?? coverage);

const res = await lintProject({
const res = await ui5lint({
rootDir,
ignorePatterns,
filePatterns,
coverage: reportCoverage,
details,
configPath: config,
config,
ui5Config,
});

Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {lintProject} from "./linter/linter.js";
import type {LintResult} from "./linter/LinterContext.js";
import SharedLanguageService from "./linter/ui5Types/SharedLanguageService.js";

export type {LintResult} from "./linter/LinterContext.js";

Expand Down Expand Up @@ -66,5 +67,5 @@ export async function ui5lint(options?: UI5LinterOptions): Promise<LintResult[]>
configPath: config,
noConfig,
ui5Config,
});
}, new SharedLanguageService());
}
6 changes: 4 additions & 2 deletions src/linter/lintWorkspace.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,12 @@ import LinterContext, {LintResult, LinterParameters, LinterOptions, FSToVirtualP
import {createReader} from "@ui5/fs/resourceFactory";
import {mergeIgnorePatterns, resolveReader} from "./linter.js";
import {UI5LintConfigType} from "../utils/ConfigManager.js";
import type SharedLanguageService from "./ui5Types/SharedLanguageService.js";

export default async function lintWorkspace(
workspace: AbstractAdapter, filePathsWorkspace: AbstractAdapter,
options: LinterOptions & FSToVirtualPathOptions, config: UI5LintConfigType, patternsMatch: Set<string>
options: LinterOptions & FSToVirtualPathOptions, config: UI5LintConfigType, patternsMatch: Set<string>,
sharedLanguageService: SharedLanguageService
): Promise<LintResult[]> {
const done = taskStart("Linting Workspace");
const {relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest} = options;
Expand Down Expand Up @@ -55,7 +57,7 @@ export default async function lintWorkspace(
lintFileTypes(params),
]);

const typeLinter = new TypeLinter(params);
const typeLinter = new TypeLinter(params, sharedLanguageService);
await typeLinter.lint();
done();
return context.generateLintResults();
Expand Down
17 changes: 11 additions & 6 deletions src/linter/linter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@ import {ProjectGraph} from "@ui5/project";
import type {AbstractReader, Resource} from "@ui5/fs";
import ConfigManager, {UI5LintConfigType} from "../utils/ConfigManager.js";
import {Minimatch} from "minimatch";
import type SharedLanguageService from "./ui5Types/SharedLanguageService.js";

export async function lintProject({
rootDir, filePatterns, ignorePatterns, coverage, details, configPath, ui5Config, noConfig,
}: LinterOptions): Promise<LintResult[]> {
}: LinterOptions, sharedLanguageService: SharedLanguageService): Promise<LintResult[]> {
if (!path.isAbsolute(rootDir)) {
throw new Error(`rootDir must be an absolute path. Received: ${rootDir}`);
}
Expand Down Expand Up @@ -74,7 +75,7 @@ export async function lintProject({
noConfig,
ui5Config,
relFsBasePath, virBasePath, relFsBasePathTest, virBasePathTest,
}, config);
}, config, sharedLanguageService);

res.forEach((result) => {
result.filePath = transformVirtualPathToFilePath(result.filePath,
Expand All @@ -89,7 +90,8 @@ export async function lintProject({

export async function lintFile({
rootDir, filePatterns, ignorePatterns, namespace, coverage, details, configPath, noConfig,
}: LinterOptions): Promise<LintResult[]> {
}: LinterOptions, sharedLanguageService: SharedLanguageService
): Promise<LintResult[]> {
let config: UI5LintConfigType = {};
if (noConfig !== true) {
const configMngr = new ConfigManager(rootDir, configPath);
Expand All @@ -112,7 +114,7 @@ export async function lintFile({
configPath,
relFsBasePath: "",
virBasePath,
}, config);
}, config, sharedLanguageService);

res.forEach((result) => {
result.filePath = transformVirtualPathToFilePath(result.filePath, "", "/");
Expand All @@ -125,7 +127,8 @@ export async function lintFile({

async function lint(
resourceReader: AbstractReader, options: LinterOptions & FSToVirtualPathOptions,
config: UI5LintConfigType
config: UI5LintConfigType,
sharedLanguageService: SharedLanguageService
): Promise<LintResult[]> {
const lintEnd = taskStart("Linting");
let {ignorePatterns, filePatterns} = options;
Expand Down Expand Up @@ -171,7 +174,9 @@ async function lint(
reader,
});

const res = await lintWorkspace(workspace, filePathsWorkspace, options, config, matchedPatterns);
const res = await lintWorkspace(
workspace, filePathsWorkspace, options, config, matchedPatterns, sharedLanguageService
);
checkUnmatchedPatterns(filePatterns, matchedPatterns);

lintEnd();
Expand Down
82 changes: 82 additions & 0 deletions src/linter/ui5Types/LanguageServiceHostProxy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import ts from "typescript";

export default class LanguageServiceHostProxy implements ts.LanguageServiceHost {
private readonly emptyLanguageServiceHost: ts.LanguageServiceHost;
private languageServiceHost: ts.LanguageServiceHost;

constructor() {
this.emptyLanguageServiceHost = this.languageServiceHost = new EmptyLanguageServiceHost();
}

setHost(languageServiceHostImpl: ts.LanguageServiceHost | null) {
this.languageServiceHost = languageServiceHostImpl ?? this.emptyLanguageServiceHost;
}

// ts.LanguageServiceHost implementation:

getCompilationSettings() {
return this.languageServiceHost.getCompilationSettings();
}

getScriptFileNames() {
return this.languageServiceHost.getScriptFileNames();
}

getScriptVersion(fileName: string) {
return this.languageServiceHost.getScriptVersion(fileName);
}

getScriptSnapshot(fileName: string) {
return this.languageServiceHost.getScriptSnapshot(fileName);
}

fileExists(filePath: string) {
return this.languageServiceHost.fileExists(filePath);
}

readFile(filePath: string) {
return this.languageServiceHost.readFile(filePath);
}

getDefaultLibFileName(options: ts.CompilerOptions) {
return this.languageServiceHost.getDefaultLibFileName(options);
}

getCurrentDirectory() {
return this.languageServiceHost.getCurrentDirectory();
}
}

export class EmptyLanguageServiceHost implements ts.LanguageServiceHost {
getCompilationSettings() {
return {};
}

getScriptFileNames() {
return [];
}

getScriptVersion() {
return "0";
}

getScriptSnapshot() {
return undefined;
}

fileExists() {
return false;
}

readFile() {
return undefined;
}

getCurrentDirectory() {
return "/";
}

getDefaultLibFileName(options: ts.CompilerOptions) {
return ts.getDefaultLibFileName(options);
}
}
52 changes: 52 additions & 0 deletions src/linter/ui5Types/SharedLanguageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import ts from "typescript";
import LanguageServiceHostProxy from "./LanguageServiceHostProxy.js";

export default class SharedLanguageService {
private readonly languageServiceHostProxy: LanguageServiceHostProxy;
private readonly languageService: ts.LanguageService;
private acquired = false;
private projectScriptVersion = 0;

constructor() {
this.languageServiceHostProxy = new LanguageServiceHostProxy();
this.languageService = ts.createLanguageService(this.languageServiceHostProxy, ts.createDocumentRegistry());
}

acquire(languageServiceHost: ts.LanguageServiceHost) {
if (this.acquired) {
throw new Error("SharedCompiler is already acquired");
}
this.acquired = true;

// Set actual LanguageServiceHost implementation
this.languageServiceHostProxy.setHost(languageServiceHost);
}

getProgram() {
if (!this.acquired) {
throw new Error("SharedCompiler is not acquired");
}

const program = this.languageService.getProgram();
if (!program) {
throw new Error("SharedCompiler failed to create a program");
}
return program;
}

release() {
if (!this.acquired) {
throw new Error("SharedCompiler is not acquired");
}

// Remove previously set LanguageServiceHost implementation
this.languageServiceHostProxy.setHost(null);

this.acquired = false;
}

getNextProjectScriptVersion() {
this.projectScriptVersion++;
return this.projectScriptVersion.toString();
}
}
34 changes: 20 additions & 14 deletions src/linter/ui5Types/TypeLinter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ts from "typescript";
import {FileContents, createVirtualCompilerHost} from "./host.js";
import {FileContents, createVirtualLanguageServiceHost} from "./host.js";
import SourceFileLinter from "./SourceFileLinter.js";
import {taskStart} from "../../utils/perf.js";
import {getLogger} from "@ui5/logger";
Expand All @@ -9,6 +9,7 @@ import {AbstractAdapter} from "@ui5/fs";
import {createAdapter, createResource} from "@ui5/fs/resourceFactory";
import {loadApiExtract} from "../../utils/ApiExtract.js";
import {CONTROLLER_BY_ID_DTS_PATH} from "../xmlTemplate/linter.js";
import type SharedLanguageService from "./SharedLanguageService.js";

const log = getLogger("linter:ui5Types:TypeLinter");

Expand Down Expand Up @@ -44,12 +45,17 @@ const DEFAULT_OPTIONS: ts.CompilerOptions = {
};

export default class TypeChecker {
#sharedLanguageService: SharedLanguageService;
#compilerOptions: ts.CompilerOptions;
#context: LinterContext;
#workspace: AbstractAdapter;
#filePathsWorkspace: AbstractAdapter;

constructor({workspace, filePathsWorkspace, context}: LinterParameters) {
constructor(
{workspace, filePathsWorkspace, context}: LinterParameters,
sharedLanguageService: SharedLanguageService
) {
this.#sharedLanguageService = sharedLanguageService;
this.#context = context;
this.#workspace = workspace;
this.#filePathsWorkspace = filePathsWorkspace;
Expand Down Expand Up @@ -98,11 +104,16 @@ export default class TypeChecker {
}
}

const host = await createVirtualCompilerHost(this.#compilerOptions, files, sourceMaps, this.#context);
const projectScriptVersion = this.#sharedLanguageService.getNextProjectScriptVersion();

const host = await createVirtualLanguageServiceHost(
this.#compilerOptions, files, sourceMaps, this.#context, projectScriptVersion
);

this.#sharedLanguageService.acquire(host);

const createProgramDone = taskStart("ts.createProgram", undefined, true);
const program = ts.createProgram(
allResources.map((resource) => resource.getPath()), this.#compilerOptions, host);
const program = this.#sharedLanguageService.getProgram();
createProgramDone();

const getTypeCheckerDone = taskStart("program.getTypeChecker", undefined, true);
Expand Down Expand Up @@ -143,26 +154,21 @@ export default class TypeChecker {
}
typeCheckDone();

this.#sharedLanguageService.release();

if (process.env.UI5LINT_WRITE_TRANSFORMED_SOURCES) {
// If requested, write out every resource that has a source map (which indicates it has been transformed)
// Loop over sourceMaps set
for (const [resourcePath, sourceMap] of sourceMaps) {
let fileContent = files.get(resourcePath);

if (typeof fileContent === "function") {
fileContent = fileContent();
}
const fileContent = files.get(resourcePath);
if (fileContent) {
await writeTransformedSources(process.env.UI5LINT_WRITE_TRANSFORMED_SOURCES,
resourcePath, fileContent, sourceMap);
}
}
// Although not being a typical transformed source, write out the byId dts file for debugging purposes
let byIdDts = files.get(CONTROLLER_BY_ID_DTS_PATH);
const byIdDts = files.get(CONTROLLER_BY_ID_DTS_PATH);
if (byIdDts) {
if (typeof byIdDts === "function") {
byIdDts = byIdDts();
}
await writeTransformedSources(process.env.UI5LINT_WRITE_TRANSFORMED_SOURCES,
CONTROLLER_BY_ID_DTS_PATH, byIdDts);
}
Expand Down
Loading

0 comments on commit 85db5fa

Please sign in to comment.