| /******************************************************************************* |
| * Copyright (c) 2020, 2021 Obeo. |
| * All rights reserved. This program and the accompanying materials |
| * are made available under the terms of the Eclipse Public License v1.0 |
| * which accompanies this distribution, and is available at |
| * http://www.eclipse.org/legal/epl-v10.html |
| * |
| * Contributors: |
| * Obeo - initial API and implementation |
| *******************************************************************************/ |
| package org.eclipse.acceleo.aql.ls.services.textdocument; |
| |
| import java.net.MalformedURLException; |
| import java.net.URI; |
| import java.net.URL; |
| import java.util.List; |
| import java.util.Objects; |
| |
| import org.eclipse.acceleo.Metamodel; |
| import org.eclipse.acceleo.Module; |
| import org.eclipse.acceleo.ModuleElement; |
| import org.eclipse.acceleo.aql.AcceleoEnvironment; |
| import org.eclipse.acceleo.aql.IAcceleoEnvironment; |
| import org.eclipse.acceleo.aql.location.AcceleoLocator; |
| import org.eclipse.acceleo.aql.location.common.AbstractLocationLink; |
| import org.eclipse.acceleo.aql.ls.AcceleoLanguageServer; |
| import org.eclipse.acceleo.aql.ls.common.AcceleoLanguageServerPositionUtils; |
| import org.eclipse.acceleo.aql.ls.services.workspace.AcceleoProject; |
| import org.eclipse.acceleo.aql.parser.AcceleoAstResult; |
| import org.eclipse.acceleo.aql.parser.AcceleoParser; |
| import org.eclipse.acceleo.aql.validation.AcceleoValidator; |
| import org.eclipse.acceleo.aql.validation.IAcceleoValidationResult; |
| import org.eclipse.acceleo.query.runtime.impl.namespace.QualifiedNameQueryEnvironment; |
| import org.eclipse.acceleo.query.runtime.namespace.IQualifiedNameLookupEngine; |
| import org.eclipse.acceleo.query.runtime.namespace.IQualifiedNameQueryEnvironment; |
| import org.eclipse.acceleo.query.runtime.namespace.IQualifiedNameResolver; |
| import org.eclipse.lsp4j.Range; |
| import org.eclipse.lsp4j.TextDocumentContentChangeEvent; |
| |
| /** |
| * Represents an Acceleo Text Document as known by the Language Server. It is maintained consistent with the |
| * contents of the client's editor thanks to the LSP-compliant notifications sent to the Language Server by |
| * the client. It is used to provide the language services back to the client. |
| * |
| * @author Florent Latombe |
| */ |
| public class AcceleoTextDocument { |
| |
| /** |
| * FIXME: move somewhere else. |
| */ |
| private static final String VALIDATION_NAMESPACE = "_reserved_::to::validate"; |
| |
| /** |
| * The {@link URI} that uniquely identifies this document. |
| */ |
| private final URI uri; |
| |
| /** |
| * The {@link AcceleoProject} that contains this text document. |
| */ |
| private AcceleoProject ownerProject; |
| |
| /** |
| * The {@link IAcceleoEnvironment} for this text document. |
| */ |
| private IAcceleoEnvironment acceleoEnvironment; |
| |
| /** |
| * The {@link String} contents of the text document. |
| */ |
| private String contents; |
| |
| // Cached values that depend on the contents. |
| /** |
| * The cached {@link AcceleoAstResult} resulting form parsing the contents of the text document. |
| */ |
| private AcceleoAstResult acceleoAstResult; |
| |
| /** |
| * The cached {@link IAcceleoValidationResult}, updated upon any changes in the contents of this document. |
| */ |
| private IAcceleoValidationResult acceleoValidationResult; |
| |
| /** |
| * Creates a new {@link AcceleoTextDocument} corresponding to the given URI and with the given initial |
| * contents. |
| * |
| * @param textDocumentUri |
| * the (non-{@code null}) {@link URI} of this text document. |
| * @param textDocumentContents |
| * the (non-{@code null}) initial contents of this text document. |
| * @param project |
| * the owner project |
| */ |
| public AcceleoTextDocument(URI textDocumentUri, String textDocumentContents, AcceleoProject project) { |
| Objects.requireNonNull(textDocumentUri); |
| Objects.requireNonNull(textDocumentContents); |
| this.ownerProject = project; |
| this.uri = textDocumentUri; |
| |
| this.setContents(textDocumentContents); |
| } |
| |
| /** |
| * Behavior triggered when the contents or environment of this document have changed. We update the cached |
| * values of the parsing and validation results. |
| */ |
| private void documentChanged() { |
| // Retrieve the parsing result first, as other services depend on it. |
| this.parseContents(); |
| |
| // And validation second, as some other services depend on it. |
| this.validateAndPublishResults(); |
| } |
| |
| /** |
| * This is called when this document is saved. Notifies external parties which may rely on this document. |
| */ |
| public void documentSaved() { |
| // TODO: we probably only want to send this notification out when the "publicly-accessible" parts of |
| // the Module have changed, like a public/protected Template/Query signature. |
| if (this.getProject() != null) { |
| // This text document belongs to an AcceleoProject which holds the AcceleoEnvironment in which |
| // this module is registered. |
| this.getProject().documentSaved(this); |
| } |
| } |
| |
| /** |
| * Sets the owner {@link AcceleoProject} of this document. |
| * |
| * @param acceleoProject |
| * the new (maybe-{@code null}) owner {@link AcceleoProject}. |
| */ |
| public void setProject(AcceleoProject acceleoProject) { |
| AcceleoProject oldProject = ownerProject; |
| this.ownerProject = acceleoProject; |
| if ((acceleoProject == null && oldProject != null) || !acceleoProject.equals(oldProject)) { |
| // When the project changes, the environment changes. |
| this.resolverChanged(); |
| } |
| } |
| |
| /** |
| * This is called to notify this {@link AcceleoTextDocument} that its contextual |
| * {@link IQualifiedNameResolver} has changed. As a result, it needs to be re-parsed and re-validated. |
| */ |
| public void resolverChanged() { |
| this.documentChanged(); |
| } |
| |
| /** |
| * Provides the owner {@link AcceleoProject} of this document. |
| * |
| * @return the (maybe-{@code null}) owner {@link AcceleoProject}. |
| */ |
| public AcceleoProject getProject() { |
| return this.ownerProject; |
| } |
| |
| /** |
| * Provides the file name without its extension. |
| * |
| * @return the file name of this text document. |
| */ |
| public String getFileNameWithoutExtension() { |
| return this.uri.toString().substring(this.uri.toString().lastIndexOf('/'), this.uri.toString() |
| .lastIndexOf('.')); |
| } |
| |
| /** |
| * Parses the contents of this document and updates the cached AST. |
| */ |
| private void parseContents() { |
| AcceleoAstResult parsingResult = null; |
| parsingResult = doParsing(this.getModuleQualifiedName(), this.contents); |
| |
| final IQualifiedNameQueryEnvironment queryEnvironment = new QualifiedNameQueryEnvironment(getProject() |
| .getResolver()); |
| acceleoEnvironment = new AcceleoEnvironment(queryEnvironment); |
| |
| for (Metamodel metamodel : parsingResult.getModule().getMetamodels()) { |
| if (metamodel.getReferencedPackage() != null) { |
| queryEnvironment.registerEPackage(metamodel.getReferencedPackage()); |
| } |
| } |
| this.acceleoAstResult = parsingResult; |
| } |
| |
| /** |
| * Performs the parsing. |
| * |
| * @param moduleQualifiedName |
| * the (non-{@code null}) qualified name of the document we are parsing. |
| * @param documentContents |
| * the (non-{@code null}) contents of the document we are parsing. |
| * @return the resulting {@link AcceleoAstResult}. |
| */ |
| private static AcceleoAstResult doParsing(String moduleQualifiedName, String documentContents) { |
| Objects.requireNonNull(moduleQualifiedName); |
| Objects.requireNonNull(documentContents); |
| AcceleoParser acceleoParser = new AcceleoParser(); |
| return acceleoParser.parse(documentContents, moduleQualifiedName); |
| } |
| |
| /** |
| * Validates the contents of this document, and caches its results. |
| */ |
| public void validateContents() { |
| IAcceleoValidationResult validationResults = null; |
| if (this.acceleoAstResult != null && this.getAcceleoEnvironment() != null) { |
| validationResults = doValidation(this.acceleoAstResult, this.getAcceleoEnvironment()); |
| } |
| this.acceleoValidationResult = validationResults; |
| } |
| |
| /** |
| * Validates the contents of this document using the {@link AcceleoValidator}. As a side effect, it |
| * registers the resulting |
| * |
| * @param acceleoAstResult |
| * the (non-{@code null}) {@link AcceleoAstResult} to validate. |
| * @param acceleoEnvironment |
| * the (non-{@code null}) {@link IAcceleoEnvironment} of the document being validated. |
| * @return the {@link IAcceleoValidationResult}. |
| */ |
| private static IAcceleoValidationResult doValidation(AcceleoAstResult acceleoAstResult, |
| IAcceleoEnvironment acceleoEnvironment) { |
| Objects.requireNonNull(acceleoAstResult); |
| Objects.requireNonNull(acceleoEnvironment); |
| |
| return validate(acceleoEnvironment, new AcceleoValidator(acceleoEnvironment, acceleoEnvironment |
| .getQueryEnvironment().getLookupEngine()), acceleoAstResult); |
| } |
| |
| /** |
| * Provides the owning {@link AcceleoLanguageServer}. |
| * |
| * @return the (maybe-{@code null}) owning {@link AcceleoLanguageServer}. |
| */ |
| public AcceleoLanguageServer getLanguageServer() { |
| if (this.getProject() != null) { |
| return this.getProject().getLanguageServer(); |
| } else { |
| return null; |
| } |
| } |
| |
| /** |
| * Provides the {@link AcceleoTextDocumentService} of the owning {@link AcceleoLanguageServer}. |
| * |
| * @return the (maybe-{@code null}) {@link AcceleoTextDocumentService}. |
| */ |
| public AcceleoTextDocumentService getTextDocumentService() { |
| AcceleoLanguageServer languageServer = this.getLanguageServer(); |
| if (languageServer != null) { |
| return languageServer.getTextDocumentService(); |
| } else { |
| return null; |
| } |
| } |
| |
| /** |
| * Publishes the cached validation results. |
| */ |
| public void publishValidationResults() { |
| AcceleoTextDocumentService service = this.getTextDocumentService(); |
| if (service != null) { |
| service.publishValidationResults(this); |
| } |
| } |
| |
| /** |
| * Validates this document and publishes the validation results. |
| */ |
| public void validateAndPublishResults() { |
| this.validateContents(); |
| this.publishValidationResults(); |
| } |
| |
| /** |
| * Provides the qualified name of the Acceleo {@link Module} represented by this document, as computed by |
| * the {@link AcceleoEnvironment}'s {@link IQualifiedNameResolver}. |
| * |
| * @return the qualified name of the {@link Module}. {@code null} if this document has no |
| * {@link IAcceleoEnvironment}. |
| */ |
| public String getModuleQualifiedName() { |
| if (getProject() == null) { |
| return null; |
| } else { |
| return getProject().getResolver().getQualifiedName(this.getUrl()); |
| } |
| } |
| |
| /** |
| * Provides the {@link URL} version of this document's {@link URI}. |
| * |
| * @return the corresponding {@link URI}. |
| */ |
| public URL getUrl() { |
| try { |
| return this.getUri().toURL(); |
| } catch (MalformedURLException urlException) { |
| throw new RuntimeException("Could not convert into a URL the URI of document " + this.getUri() |
| .toString(), urlException); |
| } |
| } |
| |
| /** |
| * Provides the links from the given position to the location(s) defining the element at the given |
| * position. |
| * |
| * @param position |
| * the (positive) position in the source contents. |
| * @return the {@link List} of {@link AbstractLocationLink} corresponding to the definition location(s) of |
| * the Acceleo element found at the given position in the source contents. |
| */ |
| public List<AbstractLocationLink<?, ?>> getDefinitionLocations(int position) { |
| IAcceleoEnvironment env = getAcceleoEnvironment(); |
| // FIXME we need any module element |
| ModuleElement moduleElement = acceleoAstResult.getModule().getModuleElements().get(0); |
| final IQualifiedNameLookupEngine lookupEngine = env.getQueryEnvironment().getLookupEngine(); |
| lookupEngine.pushImportsContext(getModuleQualifiedName(), getModuleQualifiedName()); |
| List<AbstractLocationLink<?, ?>> definitionLocations = new AcceleoLocator(env, env |
| .getQueryEnvironment().getLookupEngine()).getDefinitionLocations(this.acceleoAstResult, |
| position); |
| lookupEngine.popContext(getModuleQualifiedName()); |
| return definitionLocations; |
| } |
| |
| /** |
| * Provides the links from the given position to the location(s) declaring the element at the given |
| * position. |
| * |
| * @param position |
| * the (positive) position in the source contents. |
| * @return the {@link List} of {@link AbstractLocationLink} corresponding to the declaration location(s) |
| * of the Acceleo element found at the given position in the source contents. |
| */ |
| public List<AbstractLocationLink<?, ?>> getDeclarationLocations(int position) { |
| return new AcceleoLocator(this.getAcceleoEnvironment(), this.getAcceleoEnvironment() |
| .getQueryEnvironment().getLookupEngine()).getDeclarationLocations(this.acceleoAstResult, |
| position); |
| } |
| |
| /** |
| * Provides the URI of this {@link AcceleoTextDocument}. |
| * |
| * @return the {@link URI} of this {@link AcceleoTextDocument}. |
| */ |
| public URI getUri() { |
| return this.uri; |
| } |
| |
| /** |
| * Applies {@link TextDocumentContentChangeEvent TextDocumentContentChangeEvents} to the contents of this |
| * text document. |
| * |
| * @param textDocumentContentchangeEvents |
| * the {@link Iterable} of {@link TextDocumentContentChangeEvent} to apply, in the same order. |
| * @return this {@link AcceleoTextDocument}, with the new contents resulting from applying the changes. |
| */ |
| public AcceleoTextDocument applyChanges( |
| Iterable<TextDocumentContentChangeEvent> textDocumentContentchangeEvents) { |
| String newTextDocumentContents = this.contents; |
| for (TextDocumentContentChangeEvent textDocumentContentChangeEvent : textDocumentContentchangeEvents) { |
| newTextDocumentContents = apply(textDocumentContentChangeEvent, newTextDocumentContents); |
| } |
| this.setContents(newTextDocumentContents); |
| |
| return this; |
| } |
| |
| /** |
| * Updates the known contents of the text document, which triggers the parsing of the new contents. |
| * |
| * @param newTextDocumentContents |
| * the new (non-{@code null}) contents of the text document. |
| */ |
| public void setContents(String newTextDocumentContents) { |
| this.contents = newTextDocumentContents; |
| this.documentChanged(); |
| } |
| |
| /** |
| * Provides the Abstract Syntax Tree (AST) corresponding to the contents of this text document. |
| * |
| * @return the (non-{@code null}) {@link AcceleoAstResult} of this document. |
| */ |
| public AcceleoAstResult getAcceleoAstResult() { |
| return acceleoAstResult; |
| } |
| |
| /** |
| * Provides the results of the Acceleo validation of the contents of this text document. |
| * |
| * @return the (non-{@code null}) {@link IAcceleoValidationResult} of this document. |
| */ |
| public IAcceleoValidationResult getAcceleoValidationResults() { |
| return this.acceleoValidationResult; |
| } |
| |
| /** |
| * Provides the {@link IAcceleoEnvironment} associated to this text document. |
| * |
| * @return the (maybe-{@code null}) {@link IAcceleoEnvironment} associated to this |
| * {@link AcceleoTextDocument}. |
| */ |
| public IAcceleoEnvironment getAcceleoEnvironment() { |
| return acceleoEnvironment; |
| } |
| |
| /** |
| * Provides the current {@link String text contents} of this text document. |
| * |
| * @return the (non-{@code null}) {@link String} contents of this {@link AcceleoTextDocument}. |
| */ |
| public String getContents() { |
| return this.contents; |
| } |
| |
| /** |
| * Performs the Acceleo validation. |
| * |
| * @param acceleoEnvironment |
| * the (non-{@code null}) {@link IAcceleoEnvironment}. |
| * @param acceleoValidator |
| * the (non-{@code null}) {@link AcceleoValidator}. |
| * @param acceleoAstResult |
| * the (non-{@code null}) {@link AcceleoAstResult}. |
| * @return the {@link IAcceleoValidationResult}. |
| */ |
| // FIXME the "synchronized" here is an ugly but convenient way to ensure that a validation finishes before |
| // any other is triggered. Otherwise a validation can push imports which invalidates the services lookup |
| // for another validation |
| private static synchronized IAcceleoValidationResult validate(IAcceleoEnvironment acceleoEnvironment, |
| AcceleoValidator acceleoValidator, AcceleoAstResult acceleoAstResult) { |
| String moduleQualifiedNameForValidation = VALIDATION_NAMESPACE + AcceleoParser.QUALIFIER_SEPARATOR |
| + acceleoAstResult.getModule().getName(); |
| final IQualifiedNameResolver resolver = acceleoEnvironment.getQueryEnvironment().getLookupEngine() |
| .getResolver(); |
| resolver.register(moduleQualifiedNameForValidation, acceleoAstResult.getModule()); |
| |
| IAcceleoValidationResult validationResults = acceleoValidator.validate(acceleoAstResult, |
| moduleQualifiedNameForValidation); |
| return validationResults; |
| } |
| |
| /** |
| * Applies a {@link TextDocumentContentChangeEvent} to the {@link String} representing the text document |
| * contents. |
| * |
| * @param textDocumentContentChangeEvent |
| * the (non-{@code null}) {@link TextDocumentContentChangeEvent} to apply. |
| * @param inText |
| * the (non-{@code null}) current {@link String text document contents}. |
| * @return the new {@link String text document contents}. |
| */ |
| private static String apply(TextDocumentContentChangeEvent textDocumentContentChangeEvent, |
| String inText) { |
| String newTextExcerpt = textDocumentContentChangeEvent.getText(); |
| Range changeRange = textDocumentContentChangeEvent.getRange(); |
| // We can safely ignore the range length, which gets deprecated in the next version of LSP. |
| // cf. https://github.com/Microsoft/language-server-protocol/issues/9 |
| |
| if (changeRange == null) { |
| // The whole text was replaced. |
| return newTextExcerpt; |
| } else { |
| return AcceleoLanguageServerPositionUtils.replace(inText, changeRange.getStart(), changeRange |
| .getEnd(), newTextExcerpt); |
| } |
| } |
| } |