Skip to content

Language service proxy #12231

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

Merged
merged 2 commits into from
Feb 14, 2017
Merged
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
2 changes: 1 addition & 1 deletion scripts/buildProtocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ function generateProtocolFile(protocolTs: string, typeScriptServicesDts: string)
const sanityCheckProgram = getProgramWithProtocolText(protocolDts, /*includeTypeScriptServices*/ false);
const diagnostics = [...sanityCheckProgram.getSyntacticDiagnostics(), ...sanityCheckProgram.getSemanticDiagnostics(), ...sanityCheckProgram.getGlobalDiagnostics()];
if (diagnostics.length) {
const flattenedDiagnostics = diagnostics.map(d => ts.flattenDiagnosticMessageText(d.messageText, "\n")).join("\n");
const flattenedDiagnostics = diagnostics.map(d => `${ts.flattenDiagnosticMessageText(d.messageText, "\n")} at ${d.file.fileName} line ${d.start}`).join("\n");
throw new Error(`Unexpected errors during sanity check: ${flattenedDiagnostics}`);
}
return protocolDts;
Expand Down
10 changes: 10 additions & 0 deletions src/compiler/commandLineParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,16 @@ namespace ts {
name: "alwaysStrict",
type: "boolean",
description: Diagnostics.Parse_in_strict_mode_and_emit_use_strict_for_each_source_file
},
{
// A list of plugins to load in the language service
name: "plugins",
type: "list",
isTSConfigOnly: true,
element: {
name: "plugin",
type: "object"
}
}
];

Expand Down
7 changes: 6 additions & 1 deletion src/compiler/moduleNameResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,13 +675,18 @@ namespace ts {
}

export function nodeModuleNameResolver(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache): ResolvedModuleWithFailedLookupLocations {
return nodeModuleNameResolverWorker(moduleName, containingFile, compilerOptions, host, cache, /* jsOnly*/ false);
}

/* @internal */
export function nodeModuleNameResolverWorker(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, jsOnly = false): ResolvedModuleWithFailedLookupLocations {
const containingDirectory = getDirectoryPath(containingFile);
const traceEnabled = isTraceEnabled(compilerOptions, host);

const failedLookupLocations: string[] = [];
const state: ModuleResolutionState = { compilerOptions, host, traceEnabled };

const result = tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript);
const result = jsOnly ? tryResolve(Extensions.JavaScript) : (tryResolve(Extensions.TypeScript) || tryResolve(Extensions.JavaScript));
if (result && result.value) {
const { resolved, isExternalLibraryImport } = result.value;
return createResolvedModuleWithFailedLookupLocations(resolved, isExternalLibraryImport, failedLookupLocations);
Expand Down
12 changes: 9 additions & 3 deletions src/compiler/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3215,7 +3215,11 @@
NodeJs = 2
}

export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike<string[]>;
export interface PluginImport {
name: string
}

export type CompilerOptionsValue = string | number | boolean | (string | number)[] | string[] | MapLike<string[]> | PluginImport[];

export interface CompilerOptions {
allowJs?: boolean;
Expand Down Expand Up @@ -3270,6 +3274,7 @@
outDir?: string;
outFile?: string;
paths?: MapLike<string[]>;
/*@internal*/ plugins?: PluginImport[];
preserveConstEnums?: boolean;
project?: string;
/* @internal */ pretty?: DiagnosticStyle;
Expand Down Expand Up @@ -3353,7 +3358,8 @@
JS = 1,
JSX = 2,
TS = 3,
TSX = 4
TSX = 4,
External = 5
}

export const enum ScriptTarget {
Expand Down Expand Up @@ -3428,7 +3434,7 @@
/* @internal */
export interface CommandLineOptionOfListType extends CommandLineOptionBase {
type: "list";
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType;
element: CommandLineOptionOfCustomType | CommandLineOptionOfPrimitiveType | TsConfigOnlyOption;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reads weird and needs a comment if deliberate. By definition, I would assume a "TsConfigOnlyOption" can't be provided as a CommandLineOption?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's how everything here is named. Note that CommandLineOptionBase has an isTSConfigOnly property, which you would think would have to be false by definition

}

/* @internal */
Expand Down
14 changes: 14 additions & 0 deletions src/harness/fourslash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,20 @@ namespace FourSlash {
// Create map between fileName and its content for easily looking up when resolveReference flag is specified
this.inputFiles.set(file.fileName, file.content);
if (ts.getBaseFileName(file.fileName).toLowerCase() === "tsconfig.json") {
const configJson = ts.parseConfigFileTextToJson(file.fileName, file.content);
if (configJson.config === undefined) {
throw new Error(`Failed to parse test tsconfig.json: ${configJson.error.messageText}`);
}

// Extend our existing compiler options so that we can also support tsconfig only options
if (configJson.config.compilerOptions) {
const baseDirectory = ts.normalizePath(ts.getDirectoryPath(file.fileName));
const tsConfig = ts.convertCompilerOptionsFromJson(configJson.config.compilerOptions, baseDirectory, file.fileName);

if (!tsConfig.errors || !tsConfig.errors.length) {
compilationOptions = ts.extend(compilationOptions, tsConfig.options);
}
}
configFileName = file.fileName;
}

Expand Down
89 changes: 85 additions & 4 deletions src/harness/harnessLanguageService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ namespace Harness.LanguageService {
protected virtualFileSystem: Utils.VirtualFileSystem = new Utils.VirtualFileSystem(virtualFileSystemRoot, /*useCaseSensitiveFilenames*/false);

constructor(protected cancellationToken = DefaultHostCancellationToken.Instance,
protected settings = ts.getDefaultCompilerOptions()) {
protected settings = ts.getDefaultCompilerOptions()) {
}

public getNewLine(): string {
Expand All @@ -135,7 +135,7 @@ namespace Harness.LanguageService {

public getFilenames(): string[] {
const fileNames: string[] = [];
for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()){
for (const virtualEntry of this.virtualFileSystem.getAllFileEntries()) {
const scriptInfo = virtualEntry.content;
if (scriptInfo.isRootFile) {
// only include root files here
Expand Down Expand Up @@ -211,8 +211,8 @@ namespace Harness.LanguageService {
readDirectory(path: string, extensions?: string[], exclude?: string[], include?: string[]): string[] {
return ts.matchFiles(path, extensions, exclude, include,
/*useCaseSensitiveFileNames*/false,
this.getCurrentDirectory(),
(p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p));
this.getCurrentDirectory(),
(p) => this.virtualFileSystem.getAccessibleFileSystemEntries(p));
}
readFile(path: string): string {
const snapshot = this.getScriptSnapshot(path);
Expand Down Expand Up @@ -724,6 +724,87 @@ namespace Harness.LanguageService {
createHash(s: string) {
return s;
}

require(_initialDir: string, _moduleName: string): ts.server.RequireResult {
switch (_moduleName) {
// Adds to the Quick Info a fixed string and a string from the config file
// and replaces the first display part
case "quickinfo-augmeneter":
return {
module: () => ({
create(info: ts.server.PluginCreateInfo) {
const proxy = makeDefaultProxy(info);
const langSvc: any = info.languageService;
proxy.getQuickInfoAtPosition = function () {
const parts = langSvc.getQuickInfoAtPosition.apply(langSvc, arguments);
if (parts.displayParts.length > 0) {
parts.displayParts[0].text = "Proxied";
}
parts.displayParts.push({ text: info.config.message, kind: "punctuation" });
return parts;
};

return proxy;
}
}),
error: undefined
};

// Throws during initialization
case "create-thrower":
return {
module: () => ({
create() {
throw new Error("I am not a well-behaved plugin");
}
}),
error: undefined
};

// Adds another diagnostic
case "diagnostic-adder":
return {
module: () => ({
create(info: ts.server.PluginCreateInfo) {
const proxy = makeDefaultProxy(info);
proxy.getSemanticDiagnostics = function (filename: string) {
const prev = info.languageService.getSemanticDiagnostics(filename);
const sourceFile: ts.SourceFile = info.languageService.getSourceFile(filename);
prev.push({
category: ts.DiagnosticCategory.Warning,
file: sourceFile,
code: 9999,
length: 3,
messageText: `Plugin diagnostic`,
start: 0
});
return prev;
}
return proxy;
}
}),
error: undefined
};

default:
return {
module: undefined,
error: "Could not resolve module"
};
}

function makeDefaultProxy(info: ts.server.PluginCreateInfo) {
// tslint:disable-next-line:no-null-keyword
const proxy = Object.create(null);
const langSvc: any = info.languageService;
for (const k of Object.keys(langSvc)) {
proxy[k] = function () {
return langSvc[k].apply(langSvc, arguments);
};
}
return proxy;
}
}
}

export class ServerLanguageServiceAdapter implements LanguageServiceAdapter {
Expand Down
109 changes: 106 additions & 3 deletions src/server/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,19 +91,38 @@ namespace ts.server {
}
}

export interface PluginCreateInfo {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is still a priority, but should plugins be given the opportunity to be well-behaved with respect to a cancellation token?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1, plugins should be monitoring cancellation requests similar to normal LS methods

project: Project;
languageService: LanguageService;
languageServiceHost: LanguageServiceHost;
serverHost: ServerHost;
config: any;
}

export interface PluginModule {
create(createInfo: PluginCreateInfo): LanguageService;
getExternalFiles?(proj: Project): string[];
}

export interface PluginModuleFactory {
(mod: { typescript: typeof ts }): PluginModule;
}

export abstract class Project {
private rootFiles: ScriptInfo[] = [];
private rootFilesMap: FileMap<ScriptInfo> = createFileMap<ScriptInfo>();
private lsHost: LSHost;
private program: ts.Program;

private cachedUnresolvedImportsPerFile = new UnresolvedImportsMap();
private lastCachedUnresolvedImportsList: SortedReadonlyArray<string>;

private readonly languageService: LanguageService;
// wrapper over the real language service that will suppress all semantic operations
protected languageService: LanguageService;

public languageServiceEnabled = true;

protected readonly lsHost: LSHost;

builder: Builder;
/**
* Set of files names that were updated since the last call to getChangesSinceVersion.
Expand Down Expand Up @@ -150,6 +169,17 @@ namespace ts.server {
return this.cachedUnresolvedImportsPerFile;
}

public static resolveModule(moduleName: string, initialDir: string, host: ServerHost, log: (message: string) => void): {} {
Copy link
Member

@weswigham weswigham Nov 15, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not just reuse the module resolver the TS compiler uses elsewhere? I understand that the module resolver is a lot more complicated thanks to its exposure the the compiler options, but it should be configurable to load just JS, right? At least, it'd probably be better not to duplicate the folder traversal/lookup logic in two places?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered this but didn't think it was worthwhile given that we'd have to mock out a CompilerOptions guaranteed to behave the same as NodeJS's resolution (which still differs from our own given how we crack open package.jsons and other stuff). I'm actually going to switch this to just use require directly anyway now that I've learned how to set up a different initial search path.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@RyanCavanaugh Shouldn't require at least be abstracted behind a call to sys?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@weswigham I don't really see a point - is anyone plausibly going to write a plugin that works under a host that's not node?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what about substituting require in unit tests?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And I'm sure someone like @basart would like to support angular and other plugins in his cloud IDE.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌹

Copy link
Member

@DanielRosenwasser DanielRosenwasser Dec 29, 2016

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really see a point - is anyone plausibly going to write a plugin that works under a host that's not node?

@RyanCavanaugh Isn't there an intent for this to work in Visual Studio? Our Chakra host would need to implement its own require logic in that case.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@DanielRosenwasser VS17 uses tsserver + node so it won't be a problem

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's already been refactored into sys as suggested.

const resolvedPath = normalizeSlashes(host.resolvePath(combinePaths(initialDir, "node_modules")));
log(`Loading ${moduleName} from ${initialDir} (resolved to ${resolvedPath})`);
const result = host.require(resolvedPath, moduleName);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would wrap this in a try/catch block and log the exception

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or just do this in enableProxy

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require can't throw. It returns a module or an error.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you please put it as a comment to ServerHost.require since we don't have any other ways to declare/enforce this contract?

if (result.error) {
log(`Failed to load module: ${JSON.stringify(result.error)}`);
return undefined;
}
return result.module;
}

constructor(
private readonly projectName: string,
readonly projectKind: ProjectKind,
Expand Down Expand Up @@ -237,6 +267,10 @@ namespace ts.server {
abstract getProjectRootPath(): string | undefined;
abstract getTypeAcquisition(): TypeAcquisition;

getExternalFiles(): string[] {
return [];
}

getSourceFile(path: Path) {
if (!this.program) {
return undefined;
Expand Down Expand Up @@ -804,10 +838,12 @@ namespace ts.server {
private typeRootsWatchers: FileWatcher[];
readonly canonicalConfigFilePath: NormalizedPath;

private plugins: PluginModule[] = [];

/** Used for configured projects which may have multiple open roots */
openRefCount = 0;

constructor(configFileName: NormalizedPath,
constructor(private configFileName: NormalizedPath,
projectService: ProjectService,
documentRegistry: ts.DocumentRegistry,
hasExplicitListOfFiles: boolean,
Expand All @@ -817,12 +853,64 @@ namespace ts.server {
public compileOnSaveEnabled: boolean) {
super(configFileName, ProjectKind.Configured, projectService, documentRegistry, hasExplicitListOfFiles, languageServiceEnabled, compilerOptions, compileOnSaveEnabled);
this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName));
this.enablePlugins();
}

getConfigFilePath() {
return this.getProjectName();
}

enablePlugins() {
const host = this.projectService.host;
const options = this.getCompilerOptions();
const log = (message: string) => {
this.projectService.logger.info(message);
};

if (!(options.plugins && options.plugins.length)) {
this.projectService.logger.info("No plugins exist");
// No plugins
return;
}

if (!host.require) {
this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded");
return;
}

for (const pluginConfigEntry of options.plugins) {
const searchPath = getDirectoryPath(this.configFileName);
const resolvedModule = <PluginModuleFactory>Project.resolveModule(pluginConfigEntry.name, searchPath, host, log);
if (resolvedModule) {
this.enableProxy(resolvedModule, pluginConfigEntry);
}
}
}

private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) {
try {
if (typeof pluginModuleFactory !== "function") {
this.projectService.logger.info(`Skipped loading plugin ${configEntry.name} because it did expose a proper factory function`);
return;
}

const info: PluginCreateInfo = {
config: configEntry,
project: this,
languageService: this.languageService,
languageServiceHost: this.lsHost,
serverHost: this.projectService.host
};

const pluginModule = pluginModuleFactory({ typescript: ts });
this.languageService = pluginModule.create(info);
this.plugins.push(pluginModule);
}
catch (e) {
this.projectService.logger.info(`Plugin activation failed: ${e}`);
}
}

getProjectRootPath() {
return getDirectoryPath(this.getConfigFilePath());
}
Expand All @@ -839,6 +927,21 @@ namespace ts.server {
return this.typeAcquisition;
}

getExternalFiles(): string[] {
const items: string[] = [];
for (const plugin of this.plugins) {
if (typeof plugin.getExternalFiles === "function") {
try {
items.push(...plugin.getExternalFiles(this));
}
catch (e) {
this.projectService.logger.info(`A plugin threw an exception in getExternalFiles: ${e}`);
}
}
}
return items;
}

watchConfigFile(callback: (project: ConfiguredProject) => void) {
this.projectFileWatcher = this.projectService.host.watchFile(this.getConfigFilePath(), _ => callback(this));
}
Expand Down
Loading