diff --git a/docs/Validation.md b/docs/Validation.md index 6fc7cf72..f93247b1 100644 --- a/docs/Validation.md +++ b/docs/Validation.md @@ -202,6 +202,18 @@ Please note that you can use wildcards in the pattern (ex: `foo*.xml`): In this case, all XML files that start with foo and end with .xml will be associated with the XSD (foo1.xml, foo2.xml, etc) +You can also use the following three variables in either the `pattern` or `systemId`: + + | Variable | Meaning | + | --------------------------- | ------------------------------------------------------------------------ | + | ${workspaceFolder} | The absolute path to root folder of the workspace that is currently open | + | ${fileDirname} | The absolute path to the folder of the file that is currently opened | + | ${fileBasenameNoExtension} | The current opened file's basename with no file extension | + +If one of the variables for an association can't be expanded (eg. because vscode is opened in rootless mode), +the association is ignored. +This feature is specific to the VSCode client. + ## Validation with DTD grammar To associate your XML with a DTD grammar you can use several strategies: @@ -325,7 +337,17 @@ Please note that you can use wildcards in the pattern (ex: `foo*.xml`): In this case, all XML files that start with foo and end with .xml will be associated with the DTD (foo1.xml, foo2.xml, etc) +You can also use the following three variables in either the `pattern` or `systemId`: + + | Variable | Meaning | + | --------------------------- | ------------------------------------------------------------------------ | + | ${workspaceFolder} | The absolute path to root folder of the workspace that is currently open | + | ${fileDirname} | The absolute path to the folder of the file that is currently opened | + | ${fileBasenameNoExtension} | The current opened file's basename with no file extension | +If one of the variables for an association can't be expanded (eg. because vscode is opened in rootless mode), +the association is ignored. +This feature is specific to the VSCode client. # Other Validation Settings diff --git a/src/extension.ts b/src/extension.ts index ea0dba8c..71e6fc54 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,32 +10,18 @@ * Microsoft Corporation - Auto Closing Tags */ -import { prepareExecutable } from './javaServerStarter'; -import { - LanguageClientOptions, - RevealOutputChannelOn, - LanguageClient, - DidChangeConfigurationNotification, - RequestType, - TextDocumentPositionParams, - ReferencesRequest, - NotificationType, - MessageType, - ConfigurationRequest, - ConfigurationParams, - ExecuteCommandParams, - CancellationToken, - ExecuteCommandRequest, TextDocumentIdentifier -} from 'vscode-languageclient'; -import * as requirements from './requirements'; -import { languages, IndentAction, workspace, window, commands, ExtensionContext, TextDocument, Position, LanguageConfiguration, Uri, extensions, Command, TextEditor } from "vscode"; -import * as path from 'path'; import * as os from 'os'; -import { activateTagClosing, AutoCloseResult } from './tagClosing'; +import * as path from 'path'; +import { Command, commands, ExtensionContext, extensions, IndentAction, LanguageConfiguration, languages, Position, TextDocument, TextEditor, Uri, window, workspace } from "vscode"; +import { CancellationToken, ConfigurationParams, ConfigurationRequest, DidChangeConfigurationNotification, ExecuteCommandParams, ExecuteCommandRequest, LanguageClient, LanguageClientOptions, MessageType, NotificationType, ReferencesRequest, RequestType, RevealOutputChannelOn, TextDocumentIdentifier, TextDocumentPositionParams } from 'vscode-languageclient'; import { Commands } from './commands'; -import { getXMLConfiguration, onConfigurationChange, subscribeJDKChangeConfiguration } from './settings'; -import { collectXmlJavaExtensions, onExtensionChange } from './plugin'; +import { prepareExecutable } from './javaServerStarter'; import { markdownPreviewProvider } from "./markdownPreviewProvider"; +import { collectXmlJavaExtensions, onExtensionChange } from './plugin'; +import * as requirements from './requirements'; +import { getXMLConfiguration, onConfigurationChange, subscribeJDKChangeConfiguration } from './settings'; +import { activateTagClosing, AutoCloseResult } from './tagClosing'; +import { containsVariableReferenceToCurrentFile, getVariableSubstitutedAssociations } from './variableSubstitution'; export interface ScopeInfo { scope: "default" | "global" | "workspace" | "folder"; @@ -340,6 +326,14 @@ export function activate(context: ExtensionContext) { } return result; }); + // When the current document changes, update variable values that refer to the current file if these variables are referenced, + // and send the updated settings to the server + context.subscriptions.push(window.onDidChangeActiveTextEditor(() => { + if (containsVariableReferenceToCurrentFile(getXMLConfiguration().get('fileAssociations') as XMLFileAssociation[])) { + languageClient.sendNotification(DidChangeConfigurationNotification.type, { settings: getXMLSettings(requirements.java_home) }); + onConfigurationChange(); + } + })); const api: XMLExtensionApi = { // add API set catalogs to internal memory @@ -441,11 +435,8 @@ export function activate(context: ExtensionContext) { xml['xml']['catalogs'].push(catalog); } }) - externalXmlSettings.xmlFileAssociations.forEach(element => { - if (!xml['xml']['fileAssociations'].some(fileAssociation => fileAssociation.systemId === element.systemId)) { - xml['xml']['fileAssociations'].push(element); - } - }); + // Apply variable substitutions for file associations + xml['xml']['fileAssociations'] = [...getVariableSubstitutedAssociations(xml['xml']['fileAssociations'])]; return xml; } diff --git a/src/variableSubstitution.ts b/src/variableSubstitution.ts new file mode 100644 index 00000000..4ec0aedb --- /dev/null +++ b/src/variableSubstitution.ts @@ -0,0 +1,155 @@ +import * as path from "path"; +import { TextDocument, window, workspace, WorkspaceFolder } from "vscode"; +import { XMLFileAssociation } from "./extension"; + +/** + * Represents a variable that refers to a value that can be resolved + */ +class VariableSubstitution { + + private varName: string; + private varKind: VariableSubstitutionKind; + private getValue: (currentFileUri: string, currentWorkspaceUri: string) => string; + private replaceRegExp: RegExp | undefined; + + /** + * + * @param name the name of this variable + * @param kind the kind of this variable + * @param getValue a function that resolves the value of the variable + */ + constructor(name: string, kind: VariableSubstitutionKind, getValue: (currentFileUri: string, currentWorkspaceUri: string) => string) { + this.varName = name; + this.varKind = kind; + this.getValue = getValue; + } + + public get name(): string { + return this.varName; + } + + public get kind(): VariableSubstitutionKind { + return this.varKind; + } + + /** + * Returns the string with the references to this variable replaced with the value, or the original string if the value cannot be resolved + * + * @param original the string to substitute variable value + * @param currentFileUri the uri of the currently focused file, as a string + * @param currentWorkspaceUri the uri of the root of the currently open workspace, as a string + * @returns the string with the references to this variable replaced with the value, or the original string if the value cannot be resolved + */ + public substituteString(original: string, currentFileUri: string, currentWorkspaceUri: string): string { + const value: string = this.getValue(currentFileUri, currentWorkspaceUri); + return value ? original.replace(this.getReplaceRegExp(), value) : original; + } + + /** + * Returns a regex that matches all references to the variable + * + * Lazily initialized + * + * @returns a regex that matches all references to the variable + */ + public getReplaceRegExp(): RegExp { + if (!this.replaceRegExp) { + this.replaceRegExp = new RegExp('\\$\\{' + `${this.name}` + '\\}', 'g'); + } + return this.replaceRegExp; + } +} + +enum VariableSubstitutionKind { + Workspace, + File +} + +// A list of all variable substitutions. To add a new variable substitution, add an entry to this list +const VARIABLE_SUBSTITUTIONS: VariableSubstitution[] = [ + new VariableSubstitution( + "workspaceFolder", + VariableSubstitutionKind.Workspace, + (currentFileUri: string, currentWorkspaceUri: string): string => { + return currentWorkspaceUri; + } + ), + new VariableSubstitution( + "fileDirname", + VariableSubstitutionKind.File, + (currentFileUri: string, currentWorkspaceUri: string): string => { + return path.dirname(currentFileUri); + } + ), + new VariableSubstitution( + "fileBasenameNoExtension", + VariableSubstitutionKind.File, + (currentFileUri: string, currentWorkspaceUri: string): string => { + return path.basename(currentFileUri, path.extname(currentFileUri)); + } + ) +]; + +/** + * Returns the file associations with as many variable references resolved as possible + * + * @param associations the file associations to resolve the variable references in + * @returns the file associations with as many variable references resolved as possible + */ +export function getVariableSubstitutedAssociations(associations: XMLFileAssociation[]): XMLFileAssociation[] { + + // Collect properties needed to resolve variables + const currentFile: TextDocument = window.activeTextEditor.document; + const currentFileUri: string = currentFile && currentFile.uri.fsPath; + const currentWorkspace: WorkspaceFolder = workspace.getWorkspaceFolder(currentFile && currentFile.uri); + const currentWorkspaceUri: string = (currentWorkspace && currentWorkspace.uri.fsPath) + || (workspace.workspaceFolders && workspace.workspaceFolders[0].uri.fsPath); + + // Remove variables that can't be resolved + let variablesToSubstitute = VARIABLE_SUBSTITUTIONS; + if (!currentWorkspaceUri) { + variablesToSubstitute = variablesToSubstitute.filter(variable => { variable.kind !== VariableSubstitutionKind.Workspace }); + } + if (!currentFileUri) { + variablesToSubstitute = variablesToSubstitute.filter(variable => { variable.kind !== VariableSubstitutionKind.File }); + } + + /** + * Returns the string with the values for all the variables that can be resolved substituted in the string + * + * @param val the value to substitute the variables into + * @return the string with the values for all the variables that can be resolved substituted in the string + */ + const subVars = (val: string): string => { + let newVal = val; + for (const settingVariable of variablesToSubstitute) { + newVal = settingVariable.substituteString(newVal, currentFileUri, currentWorkspaceUri); + } + return newVal; + } + + return associations.map((association: XMLFileAssociation) => { + return { + pattern: subVars(association.pattern), + systemId: subVars(association.systemId) + }; + }); +} + +/** + * Returns true if any of the file associations contain references to variables that refer to current file, and false otherwise + * + * @param associations A list of file associations to check + * @returns true if any of the file associations contain references to variables that refer to current file, and false otherwise + */ +export function containsVariableReferenceToCurrentFile(associations: XMLFileAssociation[]): boolean { + const fileVariables: VariableSubstitution[] = VARIABLE_SUBSTITUTIONS.filter(variable => variable.kind === VariableSubstitutionKind.File); + for (const association of associations) { + for (const variable of fileVariables) { + if (variable.getReplaceRegExp().test(association.pattern) || variable.getReplaceRegExp().test(association.systemId)) { + return true; + } + } + } + return false; +}