mirror of
https://github.com/microsoft/TypeScript.git
synced 2026-06-14 07:02:44 -05:00
Merge pull request #28106 from minestarks/configure-plugins
configurePlugins command for tsserver
This commit is contained in:
@@ -694,6 +694,10 @@ namespace ts.server {
|
||||
return response.body!.map(entry => this.decodeSpan(entry, fileName)); // TODO: GH#18217
|
||||
}
|
||||
|
||||
configurePlugin(pluginName: string, configuration: any): void {
|
||||
this.processRequest<protocol.ConfigurePluginRequest>("configurePlugin", { pluginName, configuration });
|
||||
}
|
||||
|
||||
getIndentationAtPosition(_fileName: string, _position: number, _options: EditorOptions): number {
|
||||
return notImplemented();
|
||||
}
|
||||
|
||||
@@ -3399,6 +3399,10 @@ Actual: ${stringify(fullActual)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public configurePlugin(pluginName: string, configuration: any): void {
|
||||
(<ts.server.SessionClient>this.languageService).configurePlugin(pluginName, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTextRangeForTextChanges({ pos, end }: ts.TextRange, textChanges: ReadonlyArray<ts.TextChange>): ts.TextRange {
|
||||
@@ -3462,19 +3466,20 @@ Actual: ${stringify(fullActual)}`);
|
||||
function runCode(code: string, state: TestState): void {
|
||||
// Compile and execute the test
|
||||
const wrappedCode =
|
||||
`(function(test, goTo, verify, edit, debug, format, cancellation, classification, verifyOperationIsCancelled) {
|
||||
`(function(test, goTo, plugins, verify, edit, debug, format, cancellation, classification, verifyOperationIsCancelled) {
|
||||
${code}
|
||||
})`;
|
||||
try {
|
||||
const test = new FourSlashInterface.Test(state);
|
||||
const goTo = new FourSlashInterface.GoTo(state);
|
||||
const plugins = new FourSlashInterface.Plugins(state);
|
||||
const verify = new FourSlashInterface.Verify(state);
|
||||
const edit = new FourSlashInterface.Edit(state);
|
||||
const debug = new FourSlashInterface.Debug(state);
|
||||
const format = new FourSlashInterface.Format(state);
|
||||
const cancellation = new FourSlashInterface.Cancellation(state);
|
||||
const f = eval(wrappedCode);
|
||||
f(test, goTo, verify, edit, debug, format, cancellation, FourSlashInterface.Classification, verifyOperationIsCancelled);
|
||||
f(test, goTo, plugins, verify, edit, debug, format, cancellation, FourSlashInterface.Classification, verifyOperationIsCancelled);
|
||||
}
|
||||
catch (err) {
|
||||
throw err;
|
||||
@@ -3974,6 +3979,15 @@ namespace FourSlashInterface {
|
||||
}
|
||||
}
|
||||
|
||||
export class Plugins {
|
||||
constructor (private state: FourSlash.TestState) {
|
||||
}
|
||||
|
||||
public configurePlugin(pluginName: string, configuration: any): void {
|
||||
this.state.configurePlugin(pluginName, configuration);
|
||||
}
|
||||
}
|
||||
|
||||
export class GoTo {
|
||||
constructor(private state: FourSlash.TestState) {
|
||||
}
|
||||
|
||||
@@ -833,6 +833,36 @@ namespace Harness.LanguageService {
|
||||
error: undefined
|
||||
};
|
||||
|
||||
// Accepts configurations
|
||||
case "configurable-diagnostic-adder":
|
||||
let customMessage = "default message";
|
||||
return {
|
||||
module: () => ({
|
||||
create(info: ts.server.PluginCreateInfo) {
|
||||
customMessage = info.config.message;
|
||||
const proxy = makeDefaultProxy(info);
|
||||
proxy.getSemanticDiagnostics = filename => {
|
||||
const prev = info.languageService.getSemanticDiagnostics(filename);
|
||||
const sourceFile: ts.SourceFile = info.project.getSourceFile(ts.toPath(filename, /*basePath*/ undefined, ts.createGetCanonicalFileName(info.serverHost.useCaseSensitiveFileNames)))!;
|
||||
prev.push({
|
||||
category: ts.DiagnosticCategory.Error,
|
||||
file: sourceFile,
|
||||
code: 9999,
|
||||
length: 3,
|
||||
messageText: customMessage,
|
||||
start: 0
|
||||
});
|
||||
return prev;
|
||||
};
|
||||
return proxy;
|
||||
},
|
||||
onConfigurationChanged(config: any) {
|
||||
customMessage = config.message;
|
||||
}
|
||||
}),
|
||||
error: undefined
|
||||
};
|
||||
|
||||
default:
|
||||
return {
|
||||
module: undefined,
|
||||
|
||||
@@ -471,6 +471,8 @@ namespace ts.server {
|
||||
public readonly globalPlugins: ReadonlyArray<string>;
|
||||
public readonly pluginProbeLocations: ReadonlyArray<string>;
|
||||
public readonly allowLocalPluginLoads: boolean;
|
||||
private currentPluginConfigOverrides: Map<any> | undefined;
|
||||
|
||||
public readonly typesMapLocation: string | undefined;
|
||||
|
||||
public readonly syntaxOnly?: boolean;
|
||||
@@ -1667,7 +1669,7 @@ namespace ts.server {
|
||||
project.enableLanguageService();
|
||||
project.watchWildcards(createMapFromTemplate(parsedCommandLine.wildcardDirectories!)); // TODO: GH#18217
|
||||
}
|
||||
project.enablePluginsWithOptions(compilerOptions);
|
||||
project.enablePluginsWithOptions(compilerOptions, this.currentPluginConfigOverrides);
|
||||
const filesToAdd = parsedCommandLine.fileNames.concat(project.getExternalFiles());
|
||||
this.updateRootAndOptionsOfNonInferredProject(project, filesToAdd, fileNamePropertyReader, compilerOptions, parsedCommandLine.typeAcquisition!, parsedCommandLine.compileOnSave!); // TODO: GH#18217
|
||||
}
|
||||
@@ -1857,7 +1859,7 @@ namespace ts.server {
|
||||
|
||||
private createInferredProject(currentDirectory: string | undefined, isSingleInferredProject?: boolean, projectRootPath?: NormalizedPath): InferredProject {
|
||||
const compilerOptions = projectRootPath && this.compilerOptionsForInferredProjectsPerProjectRoot.get(projectRootPath) || this.compilerOptionsForInferredProjects;
|
||||
const project = new InferredProject(this, this.documentRegistry, compilerOptions, projectRootPath, currentDirectory);
|
||||
const project = new InferredProject(this, this.documentRegistry, compilerOptions, projectRootPath, currentDirectory, this.currentPluginConfigOverrides);
|
||||
if (isSingleInferredProject) {
|
||||
this.inferredProjects.unshift(project);
|
||||
}
|
||||
@@ -2806,6 +2808,16 @@ namespace ts.server {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
configurePlugin(args: protocol.ConfigurePluginRequestArguments) {
|
||||
// For any projects that already have the plugin loaded, configure the plugin
|
||||
this.forEachEnabledProject(project => project.onPluginConfigurationChanged(args.pluginName, args.configuration));
|
||||
|
||||
// Also save the current configuration to pass on to any projects that are yet to be loaded.
|
||||
// If a plugin is configured twice, only the latest configuration will be remembered.
|
||||
this.currentPluginConfigOverrides = this.currentPluginConfigOverrides || createMap();
|
||||
this.currentPluginConfigOverrides.set(args.pluginName, args.configuration);
|
||||
}
|
||||
}
|
||||
|
||||
/* @internal */
|
||||
|
||||
@@ -72,6 +72,12 @@ namespace ts.server {
|
||||
export interface PluginModule {
|
||||
create(createInfo: PluginCreateInfo): LanguageService;
|
||||
getExternalFiles?(proj: Project): string[];
|
||||
onConfigurationChanged?(config: any): void;
|
||||
}
|
||||
|
||||
export interface PluginModuleWithName {
|
||||
name: string;
|
||||
module: PluginModule;
|
||||
}
|
||||
|
||||
export type PluginModuleFactory = (mod: { typescript: typeof ts }) => PluginModule;
|
||||
@@ -92,7 +98,7 @@ namespace ts.server {
|
||||
private program: Program;
|
||||
private externalFiles: SortedReadonlyArray<string>;
|
||||
private missingFilesMap: Map<FileWatcher>;
|
||||
private plugins: PluginModule[] = [];
|
||||
private plugins: PluginModuleWithName[] = [];
|
||||
|
||||
/*@internal*/
|
||||
/**
|
||||
@@ -549,9 +555,9 @@ namespace ts.server {
|
||||
|
||||
getExternalFiles(): SortedReadonlyArray<string> {
|
||||
return toSortedArray(flatMap(this.plugins, plugin => {
|
||||
if (typeof plugin.getExternalFiles !== "function") return;
|
||||
if (typeof plugin.module.getExternalFiles !== "function") return;
|
||||
try {
|
||||
return plugin.getExternalFiles(this);
|
||||
return plugin.module.getExternalFiles(this);
|
||||
}
|
||||
catch (e) {
|
||||
this.projectService.logger.info(`A plugin threw an exception in getExternalFiles: ${e}`);
|
||||
@@ -1105,7 +1111,7 @@ namespace ts.server {
|
||||
this.rootFilesMap.delete(info.path);
|
||||
}
|
||||
|
||||
protected enableGlobalPlugins(options: CompilerOptions) {
|
||||
protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map<any> | undefined) {
|
||||
const host = this.projectService.host;
|
||||
|
||||
if (!host.require) {
|
||||
@@ -1128,12 +1134,13 @@ namespace ts.server {
|
||||
|
||||
// Provide global: true so plugins can detect why they can't find their config
|
||||
this.projectService.logger.info(`Loading global plugin ${globalPluginName}`);
|
||||
this.enablePlugin({ name: globalPluginName, global: true } as PluginImport, searchPaths);
|
||||
|
||||
this.enablePlugin({ name: globalPluginName, global: true } as PluginImport, searchPaths, pluginConfigOverrides);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[]) {
|
||||
protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined) {
|
||||
this.projectService.logger.info(`Enabling plugin ${pluginConfigEntry.name} from candidate paths: ${searchPaths.join(",")}`);
|
||||
|
||||
const log = (message: string) => {
|
||||
@@ -1143,6 +1150,14 @@ namespace ts.server {
|
||||
const resolvedModule = firstDefined(searchPaths, searchPath =>
|
||||
<PluginModuleFactory | undefined>Project.resolveModule(pluginConfigEntry.name, searchPath, this.projectService.host, log));
|
||||
if (resolvedModule) {
|
||||
const configurationOverride = pluginConfigOverrides && pluginConfigOverrides.get(pluginConfigEntry.name);
|
||||
if (configurationOverride) {
|
||||
// Preserve the name property since it's immutable
|
||||
const pluginName = pluginConfigEntry.name;
|
||||
pluginConfigEntry = configurationOverride;
|
||||
pluginConfigEntry.name = pluginName;
|
||||
}
|
||||
|
||||
this.enableProxy(resolvedModule, pluginConfigEntry);
|
||||
}
|
||||
else {
|
||||
@@ -1150,11 +1165,6 @@ namespace ts.server {
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts a new check for diagnostics. Call this if some file has updated that would cause diagnostics to be changed. */
|
||||
refreshDiagnostics() {
|
||||
this.projectService.sendProjectsUpdatedInBackgroundEvent();
|
||||
}
|
||||
|
||||
private enableProxy(pluginModuleFactory: PluginModuleFactory, configEntry: PluginImport) {
|
||||
try {
|
||||
if (typeof pluginModuleFactory !== "function") {
|
||||
@@ -1180,12 +1190,26 @@ namespace ts.server {
|
||||
}
|
||||
this.projectService.logger.info(`Plugin validation succeded`);
|
||||
this.languageService = newLS;
|
||||
this.plugins.push(pluginModule);
|
||||
this.plugins.push({ name: configEntry.name, module: pluginModule });
|
||||
}
|
||||
catch (e) {
|
||||
this.projectService.logger.info(`Plugin activation failed: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/*@internal*/
|
||||
onPluginConfigurationChanged(pluginName: string, configuration: any) {
|
||||
this.plugins.filter(plugin => plugin.name === pluginName).forEach(plugin => {
|
||||
if (plugin.module.onConfigurationChanged) {
|
||||
plugin.module.onConfigurationChanged(configuration);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Starts a new check for diagnostics. Call this if some file has updated that would cause diagnostics to be changed. */
|
||||
refreshDiagnostics() {
|
||||
this.projectService.sendProjectsUpdatedInBackgroundEvent();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1241,7 +1265,8 @@ namespace ts.server {
|
||||
documentRegistry: DocumentRegistry,
|
||||
compilerOptions: CompilerOptions,
|
||||
projectRootPath: NormalizedPath | undefined,
|
||||
currentDirectory: string | undefined) {
|
||||
currentDirectory: string | undefined,
|
||||
pluginConfigOverrides: Map<any> | undefined) {
|
||||
super(InferredProject.newName(),
|
||||
ProjectKind.Inferred,
|
||||
projectService,
|
||||
@@ -1257,7 +1282,7 @@ namespace ts.server {
|
||||
if (!projectRootPath && !projectService.useSingleInferredProject) {
|
||||
this.canonicalCurrentDirectory = projectService.toCanonicalFileName(this.currentDirectory);
|
||||
}
|
||||
this.enableGlobalPlugins(this.getCompilerOptions());
|
||||
this.enableGlobalPlugins(this.getCompilerOptions(), pluginConfigOverrides);
|
||||
}
|
||||
|
||||
addRoot(info: ScriptInfo) {
|
||||
@@ -1402,12 +1427,8 @@ namespace ts.server {
|
||||
return program && program.getResolvedProjectReferences();
|
||||
}
|
||||
|
||||
enablePlugins() {
|
||||
this.enablePluginsWithOptions(this.getCompilerOptions());
|
||||
}
|
||||
|
||||
/*@internal*/
|
||||
enablePluginsWithOptions(options: CompilerOptions) {
|
||||
enablePluginsWithOptions(options: CompilerOptions, pluginConfigOverrides: Map<any> | undefined) {
|
||||
const host = this.projectService.host;
|
||||
|
||||
if (!host.require) {
|
||||
@@ -1428,11 +1449,11 @@ namespace ts.server {
|
||||
// Enable tsconfig-specified plugins
|
||||
if (options.plugins) {
|
||||
for (const pluginConfigEntry of options.plugins) {
|
||||
this.enablePlugin(pluginConfigEntry, searchPaths);
|
||||
this.enablePlugin(pluginConfigEntry, searchPaths, pluginConfigOverrides);
|
||||
}
|
||||
}
|
||||
|
||||
this.enableGlobalPlugins(options);
|
||||
this.enableGlobalPlugins(options, pluginConfigOverrides);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -129,6 +129,7 @@ namespace ts.server.protocol {
|
||||
GetEditsForFileRename = "getEditsForFileRename",
|
||||
/* @internal */
|
||||
GetEditsForFileRenameFull = "getEditsForFileRename-full",
|
||||
ConfigurePlugin = "configurePlugin"
|
||||
|
||||
// NOTE: If updating this, be sure to also update `allCommandNames` in `harness/unittests/session.ts`.
|
||||
}
|
||||
@@ -1370,6 +1371,16 @@ namespace ts.server.protocol {
|
||||
export interface ConfigureResponse extends Response {
|
||||
}
|
||||
|
||||
export interface ConfigurePluginRequestArguments {
|
||||
pluginName: string;
|
||||
configuration: any;
|
||||
}
|
||||
|
||||
export interface ConfigurePluginRequest extends Request {
|
||||
command: CommandTypes.ConfigurePlugin;
|
||||
arguments: ConfigurePluginRequestArguments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Information found in an "open" request.
|
||||
*/
|
||||
|
||||
@@ -1953,6 +1953,10 @@ namespace ts.server {
|
||||
this.updateErrorCheck(next, checkList, delay, /*requireOpen*/ false);
|
||||
}
|
||||
|
||||
private configurePlugin(args: protocol.ConfigurePluginRequestArguments) {
|
||||
this.projectService.configurePlugin(args);
|
||||
}
|
||||
|
||||
getCanonicalFileName(fileName: string) {
|
||||
const name = this.host.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase();
|
||||
return normalizePath(name);
|
||||
@@ -2274,6 +2278,10 @@ namespace ts.server {
|
||||
[CommandNames.GetEditsForFileRenameFull]: (request: protocol.GetEditsForFileRenameRequest) => {
|
||||
return this.requiredResponse(this.getEditsForFileRename(request.arguments, /*simplifiedResult*/ false));
|
||||
},
|
||||
[CommandNames.ConfigurePlugin]: (request: protocol.ConfigurePluginRequest) => {
|
||||
this.configurePlugin(request.arguments);
|
||||
return this.notRequired();
|
||||
}
|
||||
});
|
||||
|
||||
public addProtocolHandler(command: string, handler: (request: protocol.Request) => HandlerResponse) {
|
||||
|
||||
@@ -5673,7 +5673,8 @@ declare namespace ts.server.protocol {
|
||||
GetApplicableRefactors = "getApplicableRefactors",
|
||||
GetEditsForRefactor = "getEditsForRefactor",
|
||||
OrganizeImports = "organizeImports",
|
||||
GetEditsForFileRename = "getEditsForFileRename"
|
||||
GetEditsForFileRename = "getEditsForFileRename",
|
||||
ConfigurePlugin = "configurePlugin"
|
||||
}
|
||||
/**
|
||||
* A TypeScript Server message
|
||||
@@ -6618,6 +6619,14 @@ declare namespace ts.server.protocol {
|
||||
*/
|
||||
interface ConfigureResponse extends Response {
|
||||
}
|
||||
interface ConfigurePluginRequestArguments {
|
||||
pluginName: string;
|
||||
configuration: any;
|
||||
}
|
||||
interface ConfigurePluginRequest extends Request {
|
||||
command: CommandTypes.ConfigurePlugin;
|
||||
arguments: ConfigurePluginRequestArguments;
|
||||
}
|
||||
/**
|
||||
* Information found in an "open" request.
|
||||
*/
|
||||
@@ -8044,6 +8053,11 @@ declare namespace ts.server {
|
||||
interface PluginModule {
|
||||
create(createInfo: PluginCreateInfo): LanguageService;
|
||||
getExternalFiles?(proj: Project): string[];
|
||||
onConfigurationChanged?(config: any): void;
|
||||
}
|
||||
interface PluginModuleWithName {
|
||||
name: string;
|
||||
module: PluginModule;
|
||||
}
|
||||
type PluginModuleFactory = (mod: {
|
||||
typescript: typeof ts;
|
||||
@@ -8182,11 +8196,11 @@ declare namespace ts.server {
|
||||
filesToString(writeProjectFileNames: boolean): string;
|
||||
setCompilerOptions(compilerOptions: CompilerOptions): void;
|
||||
protected removeRoot(info: ScriptInfo): void;
|
||||
protected enableGlobalPlugins(options: CompilerOptions): void;
|
||||
protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[]): void;
|
||||
protected enableGlobalPlugins(options: CompilerOptions, pluginConfigOverrides: Map<any> | undefined): void;
|
||||
protected enablePlugin(pluginConfigEntry: PluginImport, searchPaths: string[], pluginConfigOverrides: Map<any> | undefined): void;
|
||||
private enableProxy;
|
||||
/** Starts a new check for diagnostics. Call this if some file has updated that would cause diagnostics to be changed. */
|
||||
refreshDiagnostics(): void;
|
||||
private enableProxy;
|
||||
}
|
||||
/**
|
||||
* If a file is opened and no tsconfig (or jsconfig) is found,
|
||||
@@ -8227,7 +8241,6 @@ declare namespace ts.server {
|
||||
getConfigFilePath(): NormalizedPath;
|
||||
getProjectReferences(): ReadonlyArray<ProjectReference>;
|
||||
updateReferences(refs: ReadonlyArray<ProjectReference> | undefined): void;
|
||||
enablePlugins(): void;
|
||||
/**
|
||||
* Get the errors that dont have any file name associated
|
||||
*/
|
||||
@@ -8472,6 +8485,7 @@ declare namespace ts.server {
|
||||
readonly globalPlugins: ReadonlyArray<string>;
|
||||
readonly pluginProbeLocations: ReadonlyArray<string>;
|
||||
readonly allowLocalPluginLoads: boolean;
|
||||
private currentPluginConfigOverrides;
|
||||
readonly typesMapLocation: string | undefined;
|
||||
readonly syntaxOnly?: boolean;
|
||||
/** Tracks projects that we have already sent telemetry for. */
|
||||
@@ -8647,6 +8661,7 @@ declare namespace ts.server {
|
||||
applySafeList(proj: protocol.ExternalProject): NormalizedPath[];
|
||||
openExternalProject(proj: protocol.ExternalProject): void;
|
||||
hasDeferredExtension(): boolean;
|
||||
configurePlugin(args: protocol.ConfigurePluginRequestArguments): void;
|
||||
}
|
||||
}
|
||||
declare namespace ts.server {
|
||||
@@ -8817,6 +8832,7 @@ declare namespace ts.server {
|
||||
private convertTextChangeToCodeEdit;
|
||||
private getBraceMatching;
|
||||
private getDiagnosticsForProject;
|
||||
private configurePlugin;
|
||||
getCanonicalFileName(fileName: string): string;
|
||||
exit(): void;
|
||||
private notRequired;
|
||||
|
||||
@@ -124,6 +124,9 @@ declare namespace FourSlashInterface {
|
||||
symbolsInScope(range: Range): any[];
|
||||
setTypesRegistry(map: { [key: string]: void }): void;
|
||||
}
|
||||
class plugins {
|
||||
configurePlugin(pluginName: string, configuration: any): void;
|
||||
}
|
||||
class goTo {
|
||||
marker(name?: string | Marker): void;
|
||||
eachMarker(markers: ReadonlyArray<string>, action: (marker: Marker, index: number) => void): void;
|
||||
@@ -651,6 +654,7 @@ declare namespace FourSlashInterface {
|
||||
}
|
||||
declare function verifyOperationIsCancelled(f: any): void;
|
||||
declare var test: FourSlashInterface.test_;
|
||||
declare var plugins: FourSlashInterface.plugins;
|
||||
declare var goTo: FourSlashInterface.goTo;
|
||||
declare var verify: FourSlashInterface.verify;
|
||||
declare var edit: FourSlashInterface.edit;
|
||||
|
||||
22
tests/cases/fourslash/server/configurePlugin.ts
Normal file
22
tests/cases/fourslash/server/configurePlugin.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
/// <reference path="../fourslash.ts"/>
|
||||
|
||||
// @Filename: tsconfig.json
|
||||
//// {
|
||||
//// "compilerOptions": {
|
||||
//// "plugins": [
|
||||
//// { "name": "configurable-diagnostic-adder" , "message": "configured error" }
|
||||
//// ]
|
||||
//// },
|
||||
//// "files": ["a.ts"]
|
||||
//// }
|
||||
|
||||
// @Filename: a.ts
|
||||
//// let x = [1, 2];
|
||||
//// /**/
|
||||
////
|
||||
|
||||
// Test that plugin adds an error message which is able to be configured
|
||||
goTo.marker();
|
||||
verify.getSemanticDiagnostics([{ message: "configured error", code: 9999, range: { pos: 0, end: 3, fileName: "a.ts" } }]);
|
||||
plugins.configurePlugin("configurable-diagnostic-adder", { message: "new error" });
|
||||
verify.getSemanticDiagnostics([{ message: "new error", code: 9999, range: { pos: 0, end: 3, fileName: "a.ts" } }]);
|
||||
Reference in New Issue
Block a user