diff --git a/.development/esmf-checkstyle.xml b/.development/esmf-checkstyle.xml index f927584fc..71838114b 100644 --- a/.development/esmf-checkstyle.xml +++ b/.development/esmf-checkstyle.xml @@ -191,7 +191,7 @@ value="Local variable name ''{0}'' must match pattern ''{1}''."/> - + diff --git a/.github/workflows/pull-request-check.yml b/.github/workflows/pull-request-check.yml index 7a5a12102..e7e008636 100644 --- a/.github/workflows/pull-request-check.yml +++ b/.github/workflows/pull-request-check.yml @@ -124,7 +124,7 @@ jobs: cp tools/samm-cli/scripts/windows/run.bat ./${bundle}/ curl -Lo warp-packer.exe https://github.com/kirbylink/warp/releases/download/v1.3.0/windows-x64.warp-packer.exe - if [ "$(sha256sum warp-packer | cut -d' ' -f1)" != "483726e0eb10a43167c03c8e4f27a2901741affd245e5ec8ed45509f9dcd7a8a" ]; then + if [ "$(sha256sum warp-packer.exe | cut -d' ' -f1)" != "483726e0eb10a43167c03c8e4f27a2901741affd245e5ec8ed45509f9dcd7a8a" ]; then echo "Warp packer checksum does not match" exit 1 fi @@ -145,7 +145,7 @@ jobs: chmod +x ./${bundle}/run.sh curl -Lo warp-packer https://github.com/kirbylink/warp/releases/download/v1.3.0/macos-x64.warp-packer - if [ "$(sha256sum warp-packer | cut -d' ' -f1)" != "311bd238d087ea92f486d3d754a0f11826d9bd49d6f8ae849e5d6f6089a2e9c9" ]; then + if [ "$(shasum -a 256 warp-packer | cut -d' ' -f1)" != "311bd238d087ea92f486d3d754a0f11826d9bd49d6f8ae849e5d6f6089a2e9c9" ]; then echo "Warp packer checksum does not match" exit 1 fi diff --git a/core/esmf-aspect-meta-model-interface/src/main/java/org/eclipse/esmf/aspectmodel/AspectLoadingException.java b/core/esmf-aspect-meta-model-interface/src/main/java/org/eclipse/esmf/aspectmodel/AspectLoadingException.java index c4b8c7956..a9dc1f037 100644 --- a/core/esmf-aspect-meta-model-interface/src/main/java/org/eclipse/esmf/aspectmodel/AspectLoadingException.java +++ b/core/esmf-aspect-meta-model-interface/src/main/java/org/eclipse/esmf/aspectmodel/AspectLoadingException.java @@ -13,18 +13,35 @@ package org.eclipse.esmf.aspectmodel; +import org.apache.jena.rdf.model.RDFNode; +import org.jspecify.annotations.Nullable; + public class AspectLoadingException extends RuntimeException { private static final long serialVersionUID = 7687644022103150329L; + private final @Nullable RDFNode highlightElement; + public AspectLoadingException( final Throwable cause ) { super( cause ); + highlightElement = null; } public AspectLoadingException( final String message ) { super( message ); + highlightElement = null; } public AspectLoadingException( final String message, final Throwable cause ) { super( message, cause ); + highlightElement = null; + } + + public AspectLoadingException( final String message, final RDFNode highlightElement ) { + super( message ); + this.highlightElement = highlightElement; + } + + public @Nullable RDFNode highlightElement() { + return highlightElement; } } diff --git a/core/esmf-aspect-meta-model-java/pom.xml b/core/esmf-aspect-meta-model-java/pom.xml index a0a23969d..2ee6a02b7 100644 --- a/core/esmf-aspect-meta-model-java/pom.xml +++ b/core/esmf-aspect-meta-model-java/pom.xml @@ -34,6 +34,10 @@ + + org.eclipse.esmf + esmf-util + org.eclipse.esmf esmf-semantic-aspect-meta-model @@ -42,6 +46,10 @@ org.eclipse.esmf esmf-aspect-meta-model-interface + + org.eclipse.esmf + esmf-tree-sitter-turtle + org.apache.commons commons-text diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoader.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoader.java index 8f90b07d9..909fa5a1c 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoader.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoader.java @@ -38,6 +38,15 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.ModelFactory; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.Statement; +import org.apache.jena.rdf.model.StmtIterator; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.XSD; + import org.eclipse.esmf.aspectmodel.AspectLoadingException; import org.eclipse.esmf.aspectmodel.AspectModelFile; import org.eclipse.esmf.aspectmodel.RdfUtil; @@ -68,19 +77,13 @@ import org.eclipse.esmf.metamodel.impl.DefaultNamespace; import org.eclipse.esmf.metamodel.vocabulary.SammNs; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import com.google.common.collect.Streams; + import io.vavr.control.Either; import io.vavr.control.Try; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.ModelFactory; -import org.apache.jena.rdf.model.RDFNode; -import org.apache.jena.rdf.model.Resource; -import org.apache.jena.rdf.model.Statement; -import org.apache.jena.rdf.model.StmtIterator; -import org.apache.jena.vocabulary.RDF; -import org.apache.jena.vocabulary.XSD; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** * The core class to load an {@link AspectModel}. The AspectModelLoader is also a @@ -339,6 +342,23 @@ public AspectModel loadUrns( final Collection urns ) { return loadAspectModelFiles( loaderContext.loadedFiles() ); } + /** + * Load an Aspect Model from a String containing the RDF/Turtle represenatation and set the srouce + * location + * for this input + * + * @param turtleRepresentation the document as RDF/Turtle + * @param sourceLocation the source location for the model + * @return the Aspect Model + */ + public AspectModel load( final String turtleRepresentation, final URI sourceLocation ) { + final AspectModelFile rawFile = AspectModelFileLoader.load( turtleRepresentation, sourceLocation ); + final AspectModelFile migratedModel = migrate( rawFile ); + final LoaderContext loaderContext = new LoaderContext(); + resolve( List.of( migratedModel ), loaderContext ); + return loadAspectModelFiles( loaderContext.loadedFiles() ); + } + /** * Load an Aspect Model (.ttl) from an input stream and set the source location for this input. For * loading an Aspect Model Namespace Package (.zip), use @@ -640,7 +660,11 @@ public AspectModel loadAspectModelFiles( final Collection input .filter( statement -> !statement.getObject().isURIResource() || !statement.getResource().equals( SammNs.SAMM.Namespace() ) ) .map( Statement::getSubject ) .filter( RDFNode::isURIResource ) - .map( resource -> mergedModel.createResource( resource.getURI() ) ) + .map( resource -> { + final Resource newResource = mergedModel.createResource( resource.getURI() ); + TokenRegistry.updateNode( resource.asNode(), newResource.asNode() ); + return newResource; + } ) .map( resource -> modelElementFactory.create( ModelElement.class, resource ) ) .toList(); aspectModelFile.setElements( fileElements ); diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/Instantiator.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/Instantiator.java index c9c151551..a8aed45b1 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/Instantiator.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/Instantiator.java @@ -23,6 +23,14 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import org.apache.jena.rdf.model.Literal; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.RDFList; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.Statement; +import org.apache.jena.vocabulary.RDF; + import org.eclipse.esmf.aspectmodel.AspectLoadingException; import org.eclipse.esmf.aspectmodel.RdfUtil; import org.eclipse.esmf.aspectmodel.urn.AspectModelUrn; @@ -44,14 +52,6 @@ import org.eclipse.esmf.metamodel.vocabulary.SAMM; import org.eclipse.esmf.metamodel.vocabulary.SammNs; -import org.apache.jena.rdf.model.Literal; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.RDFList; -import org.apache.jena.rdf.model.RDFNode; -import org.apache.jena.rdf.model.Resource; -import org.apache.jena.rdf.model.Statement; -import org.apache.jena.vocabulary.RDF; - public abstract class Instantiator extends AttributeValueRetriever implements Function { protected final ModelElementFactory modelElementFactory; protected Class targetClass; @@ -128,7 +128,8 @@ private Statement getDataType( final Resource resource ) { final Statement dataType = resource.getProperty( SammNs.SAMM.dataType() ); if ( dataType == null ) { throw new AspectLoadingException( - String.format( "No datatype is defined on the Characteristic instance '%s: '.", resource.getLocalName() ) ); + String.format( "No datatype is defined on the Characteristic instance '%s'.", resource.getLocalName() ), + resource ); } return dataType; } ); @@ -157,7 +158,7 @@ protected Value buildValue( final RDFNode node, final Optional charact if ( node.isLiteral() ) { final Literal literal = node.asLiteral(); return valueInstantiator.buildScalarValue( literal.getLexicalForm(), literal.getLanguage(), literal.getDatatypeURI() ) - .orElseThrow( () -> new AspectLoadingException( "Literal can not be parsed: " + literal ) ); + .orElseThrow( () -> new AspectLoadingException( "Literal can not be parsed: " + literal, literal ) ); } if ( node.isResource() ) { @@ -166,7 +167,7 @@ protected Value buildValue( final RDFNode node, final Optional charact final Optional valueOpt = optionalAttributeValue( resource, SammNs.SAMM.value() ).map( Statement::getString ); if ( valueOpt.isEmpty() ) { - throw new AspectLoadingException( "samm:Value must contain a samm:value property" ); + throw new AspectLoadingException( "samm:Value must contain a samm:value property", resource ); } return new DefaultScalarValue( buildBaseAttributes( resource ), valueOpt.get(), new DefaultScalar( type.getUrn() ) ); @@ -196,7 +197,7 @@ protected Value buildValue( final RDFNode node, final Optional charact // This could happen if an entity instance should be constructed for an AbstractEntity type if ( !type.is( Entity.class ) ) { - throw new AspectLoadingException( "Expected type of value " + node + " to be samm:Entity, but it is not" ); + throw new AspectLoadingException( "Expected type of value " + node + " to be samm:Entity, but it is not", node ); } // Entities @@ -221,11 +222,12 @@ protected EntityInstance buildEntityInstance( final Resource entityInstance, fin if ( property.isOptional() ) { return; } - throw new AspectLoadingException( "Mandatory Property " + property + " not found in Entity instance " + entityInstance ); + throw new AspectLoadingException( "Mandatory Property " + property + " not found in Entity instance " + entityInstance, + entityInstance ); } final RDFNode rdfValue = entityInstance.getProperty( rdfProperty ).getObject(); final Type propertyType = property.getDataType() - .orElseThrow( () -> new AspectLoadingException( "Invalid Property without a dataType found" ) ); + .orElseThrow( () -> new AspectLoadingException( "Invalid Property without a dataType found", entityInstance ) ); final Resource characteristic = attributeValue( rdfProperty, SammNs.SAMM.characteristic() ).getResource(); final Value value = buildValue( rdfValue, Optional.of( characteristic ), propertyType ); assertions.put( property, value ); diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/ModelElementFactory.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/ModelElementFactory.java index 075bc3291..2c093f00d 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/ModelElementFactory.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/ModelElementFactory.java @@ -26,6 +26,15 @@ import java.util.stream.Collectors; import java.util.stream.Stream; +import org.apache.commons.lang3.StringUtils; +import org.apache.jena.rdf.model.Model; +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.rdf.model.Resource; +import org.apache.jena.rdf.model.Statement; +import org.apache.jena.rdf.model.StmtIterator; +import org.apache.jena.vocabulary.RDF; +import org.apache.jena.vocabulary.RDFS; + import org.eclipse.esmf.aspectmodel.AspectLoadingException; import org.eclipse.esmf.aspectmodel.AspectModelFile; import org.eclipse.esmf.aspectmodel.loader.instantiator.AbstractEntityInstantiator; @@ -77,14 +86,6 @@ import org.eclipse.esmf.metamodel.vocabulary.SammNs; import com.google.common.collect.Streams; -import org.apache.commons.lang3.StringUtils; -import org.apache.jena.rdf.model.Model; -import org.apache.jena.rdf.model.RDFNode; -import org.apache.jena.rdf.model.Resource; -import org.apache.jena.rdf.model.Statement; -import org.apache.jena.rdf.model.StmtIterator; -import org.apache.jena.vocabulary.RDF; -import org.apache.jena.vocabulary.RDFS; /** * Used as part of the loading process in the {@link AspectModelLoader}, it creates instance for the @@ -170,11 +171,12 @@ public T create( final Class clazz, final Resource m // No generic instantiator could be found. This means the element is an entity instance if ( !model.contains( targetType, RDF.type, (RDFNode) null ) ) { - throw new AspectLoadingException( "Could not load " + modelElement + ": Unknown type " + targetType ); + throw new AspectLoadingException( "Could not load " + modelElement + ": Unknown type " + targetType, modelElement ); } final Entity entity = create( Entity.class, targetType ); if ( entity == null ) { - throw new AspectLoadingException( "Could not load " + modelElement + ": Expected " + targetType + " to be an Entity" ); + throw new AspectLoadingException( "Could not load " + modelElement + ": Expected " + targetType + " to be an Entity", + modelElement ); } return (T) new EntityInstanceInstantiator( this, entity ).apply( modelElement ); } @@ -190,7 +192,7 @@ public Unit findOrCreateUnit( final Resource unitResource ) { if ( SammNs.UNIT.getNamespace().equals( unitResource.getNameSpace() ) ) { final AspectModelUrn unitUrn = AspectModelUrn.fromUrn( unitResource.getURI() ); return Units.fromName( unitUrn.getName() ) - .orElseThrow( () -> new AspectLoadingException( "Unit definition for " + unitUrn + " is invalid" ) ); + .orElseThrow( () -> new AspectLoadingException( "Unit definition for " + unitUrn + " is invalid", unitResource ) ); } final Set quantityKinds = Streams.stream( @@ -220,7 +222,7 @@ private Resource resourceType( final Resource resource ) { .filter( Optional::isPresent ) .map( Optional::get ) .findFirst() - .orElseThrow( () -> new AspectLoadingException( "Resource " + resource + " has no type" ) ); + .orElseThrow( () -> new AspectLoadingException( "Resource " + resource + " has no type", resource ) ); } protected Model getModel() { @@ -301,7 +303,8 @@ private static List getSeeValues( final Resource resource, final Attribu private static String getSyntheticName( final Resource modelElement ) { final Resource namedParent = getNamedParent( modelElement, modelElement.getModel() ); if ( namedParent == null ) { - throw new AspectLoadingException( "At least one anonymous node in the model does not have a parent with a regular name." ); + throw new AspectLoadingException( "At least one anonymous node in the model does not have a parent with a regular name.", + modelElement ); } final String parentModelElementUri = namedParent.getURI(); final String parentModelElementName = AspectModelUrn.from( parentModelElementUri ) @@ -365,7 +368,7 @@ private static Resource getModelElementType( final Resource modelElement ) { // This model element has no type, but maybe it extends another element final Statement extendsStatement = modelElement.getProperty( SammNs.SAMM._extends() ); if ( extendsStatement == null ) { - throw new AspectLoadingException( "Model element has no type and does not extend another type: " + modelElement ); + throw new AspectLoadingException( "Model element has no type and does not extend another type: " + modelElement, modelElement ); } final Resource superElement = extendsStatement.getObject().asResource(); diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/instantiator/PropertyInstantiator.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/instantiator/PropertyInstantiator.java index 192d60106..be9087cd7 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/instantiator/PropertyInstantiator.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/loader/instantiator/PropertyInstantiator.java @@ -98,7 +98,7 @@ private ScalarValue buildScalarValue( final RDFNode node, final Type expectedTyp if ( node.isLiteral() ) { final Literal literal = node.asLiteral(); return valueInstantiator.buildScalarValue( literal.getLexicalForm(), literal.getLanguage(), literal.getDatatypeURI() ) - .orElseThrow( () -> new AspectLoadingException( "Literal cannot be parsed: " + literal ) ); + .orElseThrow( () -> new AspectLoadingException( "Literal cannot be parsed: " + literal, literal ) ); } if ( node.isResource() ) { @@ -108,7 +108,7 @@ private ScalarValue buildScalarValue( final RDFNode node, final Type expectedTyp final Optional valueOpt = optionalAttributeValue( resource, SammNs.SAMM.value() ).map( Statement::getString ); if ( valueOpt.isEmpty() ) { - throw new AspectLoadingException( "samm:Value must contain a samm:value property" ); + throw new AspectLoadingException( "samm:Value must contain a samm:value property", resource ); } return new DefaultScalarValue( buildBaseAttributes( resource ), valueOpt.get(), new DefaultScalar( expectedType.toString() ) ); @@ -117,6 +117,6 @@ private ScalarValue buildScalarValue( final RDFNode node, final Type expectedTyp return new DefaultScalarValue( buildBaseAttributes( resource ), resource.getURI(), new DefaultScalar( expectedType.toString() ) ); } - throw new AspectLoadingException( "Unexpected RDF node type: " + node ); + throw new AspectLoadingException( "Unexpected RDF node type: " + node, node ); } } diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/AspectModelFileLoader.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/AspectModelFileLoader.java index 4533350bb..f63aa40f9 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/AspectModelFileLoader.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/AspectModelFileLoader.java @@ -32,15 +32,19 @@ import java.util.List; import java.util.Optional; +import org.apache.jena.rdf.model.Model; + import org.eclipse.esmf.aspectmodel.RdfUtil; import org.eclipse.esmf.aspectmodel.loader.AspectModelLoader; import org.eclipse.esmf.aspectmodel.resolver.exceptions.ModelResolutionException; import org.eclipse.esmf.aspectmodel.resolver.exceptions.ParserException; import org.eclipse.esmf.aspectmodel.resolver.modelfile.RawAspectModelFile; import org.eclipse.esmf.aspectmodel.resolver.services.TurtleLoader; +import org.eclipse.esmf.treesitterturtle.ParserTokenType; +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; +import org.eclipse.esmf.util.download.Download; import io.vavr.control.Try; -import org.apache.jena.rdf.model.Model; /** * Loads an input source into a {@link RawAspectModelFile}, i.e., an Aspect Model file that does not @@ -89,6 +93,18 @@ public static RawAspectModelFile load( final String rdfTurtle, final URI sourceL return new RawAspectModelFile( rdfTurtle, model, headerComment, Optional.of( sourceLocation ) ); } + public static RawAspectModelFile load( final TurtleSyntaxTree syntaxTree, final URI sourceLocation ) { + final String sourceRepresentation = syntaxTree.sourceRepresentationSupplier().get(); + final List headerComment = headerComment( syntaxTree ); + final Try tryModel = TurtleLoader.loadTurtle( syntaxTree, sourceLocation ); + if ( tryModel.isFailure() && tryModel.getCause() instanceof final ParserException parserException ) { + throw parserException; + } + final Model model = tryModel.getOrElseThrow( + () -> new ModelResolutionException( "Can not load model", tryModel.getCause() ) ); + return new RawAspectModelFile( sourceRepresentation, model, headerComment, Optional.of( sourceLocation ) ); + } + /** * Loads the content of an AspectModelFile from an input stream * @@ -104,7 +120,7 @@ public static RawAspectModelFile load( final InputStream inputStream, final URI * Loads the content of an AspectModelFile from an RDF model * * @param model the input model - * @param sourceLocation the logical location of the file file source + * @param sourceLocation the logical location of the file source * @return the loaded file content */ public static RawAspectModelFile load( final Model model, final URI sourceLocation ) { @@ -191,6 +207,16 @@ private static List headerComment( final String content ) { .toList(); } + private static List headerComment( final TurtleSyntaxTree turtleSyntaxTree ) { + return turtleSyntaxTree.tokens() + .filter( token -> !token.type().equals( ParserTokenType.DOCUMENT ) ) + .takeWhile( token -> token.type().equals( ParserTokenType.COMMENT ) ) + .map( TurtleSyntaxTree.Token::content ) + .dropWhile( String::isBlank ) + .map( line -> line.substring( 1 ).trim() ) + .toList(); + } + private static URI buildArtificialUri( final Object object, final String objectType ) { return URI.create( "inmemory:%s:%s".formatted( objectType, object.hashCode() ) ); } diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/CommandExecutor.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/CommandExecutor.java index 848bf869a..076716662 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/CommandExecutor.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/CommandExecutor.java @@ -18,10 +18,10 @@ import java.util.List; import java.util.Optional; -import org.eclipse.esmf.aspectmodel.resolver.exceptions.ProcessExecutionException; -import org.eclipse.esmf.aspectmodel.resolver.process.BinaryLauncher; -import org.eclipse.esmf.aspectmodel.resolver.process.ExecutableJarLauncher; -import org.eclipse.esmf.aspectmodel.resolver.process.ProcessLauncher; +import org.eclipse.esmf.util.process.ProcessExecutionException; +import org.eclipse.esmf.util.process.BinaryLauncher; +import org.eclipse.esmf.util.process.ExecutableJarLauncher; +import org.eclipse.esmf.util.process.ProcessLauncher; /** * Executes an external resolver via the underlying OS command and returns the stdout from the diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/ReaderRiotTurtle.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/ReaderRiotTurtle.java index aa0abe41e..c46f1472b 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/ReaderRiotTurtle.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/ReaderRiotTurtle.java @@ -26,28 +26,36 @@ import org.apache.jena.riot.tokens.TokenizerText; import org.apache.jena.sparql.util.Context; +import org.eclipse.esmf.aspectmodel.resolver.services.TurtleLoader; +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; + public class ReaderRiotTurtle implements ReaderRIOT { public static ReaderRIOTFactory factory = ReaderRiotTurtle::new; - - private final Lang lang; private final ParserProfile parserProfile; ReaderRiotTurtle( final Lang lang, final ParserProfile parserProfile ) { - this.lang = lang; - this.parserProfile = new TurtleParserProfile( parserProfile ); + this.parserProfile = parserProfile; } @Override public void read( final InputStream in, final String baseUri, final ContentType ct, final StreamRDF output, final Context context ) { - final TurtleTokenizer tokenizer = new TurtleTokenizer( in, parserProfile.getErrorHandler() ); - final TurtleParser parser = TurtleParser.create( tokenizer, parserProfile, output ); + final TurtleSyntaxTree syntaxTree = context != null && context.get( TurtleLoader.TREE_SITTER_SYNTAX_TREE ) != null + ? context.get( TurtleLoader.TREE_SITTER_SYNTAX_TREE ) + : null; + final ParserProfile wrappedParserProfile = new TurtleParserProfile( parserProfile, syntaxTree ); + final TurtleTokenizer tokenizer = new TurtleTokenizer( in, wrappedParserProfile.getErrorHandler() ); + final TurtleParser parser = TurtleParser.create( tokenizer, wrappedParserProfile, output ); parser.parse(); } @Override public void read( final Reader in, final String baseUri, final ContentType ct, final StreamRDF output, final Context context ) { - final Tokenizer tokenizer = TokenizerText.create().source( in ).errorHandler( parserProfile.getErrorHandler() ).build(); - final TurtleParser parser = TurtleParser.create( tokenizer, parserProfile, output ); + final TurtleSyntaxTree syntaxTree = context != null && context.get( TurtleLoader.TREE_SITTER_SYNTAX_TREE ) != null + ? context.get( TurtleLoader.TREE_SITTER_SYNTAX_TREE ) + : null; + final ParserProfile wrappedParserProfile = new TurtleParserProfile( parserProfile, syntaxTree ); + final Tokenizer tokenizer = TokenizerText.create().source( in ).errorHandler( wrappedParserProfile.getErrorHandler() ).build(); + final TurtleParser parser = TurtleParser.create( tokenizer, wrappedParserProfile, output ); parser.parse(); } } diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/SmartToken.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/SmartToken.java index f18020229..2e2ed90c6 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/SmartToken.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/SmartToken.java @@ -16,28 +16,38 @@ import java.util.Objects; import java.util.Optional; -import org.eclipse.esmf.aspectmodel.AspectModelFile; - import org.apache.jena.riot.tokens.Token; import org.apache.jena.riot.tokens.TokenType; +import org.eclipse.esmf.aspectmodel.AspectModelFile; +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; + +import org.jspecify.annotations.Nullable; + /** * Wrapper class for a {@link Token}. This provides access to the actual string representation of * the token, 1-based line and column information. */ public final class SmartToken { - private final Token token; + private final Token jenaToken; + private final TurtleSyntaxTree.Token treesitterToken; private AspectModelFile originatingFile; /** - * @param token the token + * @param jenaToken the token */ - public SmartToken( final Token token ) { - this.token = token; + public SmartToken( final Token jenaToken ) { + this.jenaToken = jenaToken; + treesitterToken = null; + } + + public SmartToken( final TurtleSyntaxTree.Token treesitterToken ) { + jenaToken = null; + this.treesitterToken = treesitterToken; } - public TokenType type() { - return token.getType(); + public @Nullable TokenType type() { + return jenaToken.getType(); } /** @@ -46,7 +56,10 @@ public TokenType type() { * @return the lexical representation */ public String image() { - return token.getImage(); + if ( jenaToken != null ) { + return jenaToken.getImage(); + } + return treesitterToken.content(); } /** @@ -55,15 +68,24 @@ public String image() { * @return the lexical representation */ public String image2() { - return token.getImage2(); + if ( jenaToken != null ) { + return jenaToken.getImage2(); + } + return ""; } - public Token subToken1() { - return token.getSubToken1(); + public @Nullable Token subToken1() { + if ( jenaToken != null ) { + return jenaToken.getSubToken1(); + } + return null; } - public Token subToken2() { - return token.getSubToken2(); + public @Nullable Token subToken2() { + if ( jenaToken != null ) { + return jenaToken.getSubToken2(); + } + return null; } /** @@ -72,16 +94,46 @@ public Token subToken2() { * @return the line */ public int line() { - return (int) token.getLine(); + if ( jenaToken != null ) { + return (int) jenaToken.getLine(); + } + return treesitterToken.location().fromLine() + 1; } /** - * The column of the token, 1-based. * + * The column of the token, 1-based. * * @return the column */ public int column() { - return (int) token.getColumn(); + if ( jenaToken != null ) { + return (int) jenaToken.getColumn(); + } + return treesitterToken.location().fromColumn() + 1; + } + + /** + * The line where the token ends, 1-based. + * + * @return the line + */ + public int toLine() { + if ( jenaToken != null ) { + return line(); + } + return treesitterToken.location().toLine() + 1; + } + + /** + * The column where the token ends, 1-based. + * + * @return the colum + */ + public int toColumn() { + if ( jenaToken != null ) { + return column() + content().length(); + } + return treesitterToken.location().toColumn() + 1; } /** @@ -93,11 +145,15 @@ public int column() { * @return the token's effective content */ public String content() { - return switch ( token.getType() ) { - case IRI -> "<" + token.getImage() + ">"; - case DIRECTIVE -> "@" + token.getImage(); + if ( treesitterToken != null ) { + return treesitterToken.content(); + } + // For Jena, only an approximation of the token can be provided + return switch ( jenaToken.getType() ) { + case IRI -> "<" + jenaToken.getImage() + ">"; + case DIRECTIVE -> "@" + jenaToken.getImage(); case PREFIXED_NAME -> - Optional.ofNullable( token.getImage() ).orElse( "" ) + ":" + Optional.ofNullable( token.getImage2() ).orElse( "" ); + Optional.ofNullable( jenaToken.getImage() ).orElse( "" ) + ":" + Optional.ofNullable( jenaToken.getImage2() ).orElse( "" ); case LT -> "<"; case GT -> ">"; case LE -> "<="; @@ -122,16 +178,17 @@ public String content() { case STAR -> "*"; case SLASH -> "/"; case RSLASH -> "\\"; - case STRING -> "\"" + token.getImage() + "\""; - case LITERAL_LANG -> String.format( "\"%s\"@%s", token.getImage(), token.getImage2() ); + case STRING -> "\"" + jenaToken.getImage() + "\""; + case LITERAL_LANG -> String.format( "\"%s\"@%s", jenaToken.getImage(), jenaToken.getImage2() ); case LITERAL_DT -> - String.format( "\"%s\"^^%s:%s", token.getImage(), token.getSubToken2().getImage(), token.getSubToken2().getImage2() ); - default -> token.getImage(); + String.format( "\"%s\"^^%s:%s", jenaToken.getImage(), jenaToken.getSubToken2().getImage(), + jenaToken.getSubToken2().getImage2() ); + default -> jenaToken.getImage(); }; } public Token token() { - return token; + return jenaToken; } @Override @@ -143,21 +200,29 @@ public boolean equals( final Object obj ) { return false; } final var that = (SmartToken) obj; - return Objects.equals( token, that.token ); + return jenaToken != null + ? Objects.equals( jenaToken, that.jenaToken ) + : Objects.equals( treesitterToken, that.treesitterToken ); } @Override public int hashCode() { - return Objects.hash( token ); + return Objects.hash( jenaToken ); } @Override public String toString() { - return "SmartToken[token=" + token + ']'; + return jenaToken != null + ? "SmartToken[jenaToken=" + jenaToken + ']' + : "SmartToken[treesitterToken=" + treesitterToken + ']'; + } + + public Token getJenaToken() { + return jenaToken; } - public Token getToken() { - return token; + public TurtleSyntaxTree.Token getTreesitterToken() { + return treesitterToken; } public AspectModelFile getOriginatingFile() { diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TokenRegistry.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TokenRegistry.java index 5c2d7b942..4ffa539a7 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TokenRegistry.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TokenRegistry.java @@ -16,10 +16,11 @@ import java.util.Map; import java.util.Optional; +import org.apache.jena.graph.Node; + import org.eclipse.esmf.aspectmodel.resolver.services.TurtleLoader; import com.google.common.collect.MapMaker; -import org.apache.jena.graph.Node; /** * This map keeps track of location information for nodes, i.e., when an RDF document is parsed @@ -44,4 +45,17 @@ public static void put( final Node node, final SmartToken token ) { public static Optional getToken( final Node node ) { return Optional.ofNullable( TOKENS.get( node ) ); } + + /** + * Replace a registered node, but keep the associated token + * + * @param oldNode the old node + * @param newNode the new node + */ + public static synchronized void updateNode( final Node oldNode, final Node newNode ) { + if ( TOKENS.containsKey( oldNode ) ) { + final SmartToken token = TOKENS.remove( oldNode ); + TOKENS.put( newNode, token ); + } + } } diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParser.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParser.java index e12ba5263..9fb8bffac 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParser.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParser.java @@ -19,7 +19,6 @@ import org.apache.jena.riot.tokens.Tokenizer; public class TurtleParser extends LangTurtle { - private TurtleParser( final Tokenizer tokenizer, final ParserProfile parserProfile, final StreamRDF output ) { super( tokenizer, parserProfile, output ); } diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParserProfile.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParserProfile.java index 21692680e..d9a04ca90 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParserProfile.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/parser/TurtleParserProfile.java @@ -13,7 +13,7 @@ package org.eclipse.esmf.aspectmodel.resolver.parser; -import org.eclipse.esmf.aspectmodel.ValueParsingException; +import java.util.List; import org.apache.jena.datatypes.RDFDatatype; import org.apache.jena.graph.Graph; @@ -28,16 +28,27 @@ import org.apache.jena.riot.tokens.TokenType; import org.apache.jena.sparql.core.Quad; +import org.eclipse.esmf.aspectmodel.ValueParsingException; +import org.eclipse.esmf.treesitterturtle.ParserTokenType; +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + /** * Customized parser profile that delegates to Jena's built-in Node generation but also registers * the nodes in the {@link TokenRegistry}, where information about the line/column/token can be * retrieved at a later time. */ public class TurtleParserProfile implements ParserProfile { + private static final Logger LOG = LoggerFactory.getLogger( TurtleParserProfile.class ); private final ParserProfile parserProfile; + private final List tokens; - public TurtleParserProfile( final ParserProfile parserProfile ) { + public TurtleParserProfile( final ParserProfile parserProfile, final @Nullable TurtleSyntaxTree syntaxTree ) { this.parserProfile = parserProfile; + tokens = syntaxTree == null ? List.of() : syntaxTree.tokens().toList(); } @Override @@ -45,11 +56,71 @@ public String getBaseURI() { return parserProfile.getBaseURI(); } + /** + * Finds the matching Tree-sitter token for a given Jena token based on line and column position. + * If no exact match is found, returns the nearest token. + * + * @param targetLine the line of the originating Jena token (1-based) + * @param targetColumn the column of the originating Jena token (1-based) + * @return the matching Tree-sitter token, or null if syntax tree is not available or no tokens + * found + */ + private TurtleSyntaxTree.@Nullable Token findMatchingTreeSitterToken( final long targetLine, final long targetColumn ) { + if ( tokens.isEmpty() ) { + return null; + } + + TurtleSyntaxTree.Token exactMatch = null; + TurtleSyntaxTree.Token nearestMatch = null; + long minDistance = Long.MAX_VALUE; + final long originatingLine = targetLine - 1; + final long originatingColumn = targetColumn - 1; + + for ( final TurtleSyntaxTree.Token treeToken : tokens ) { + if ( treeToken.type().equals( ParserTokenType.OBJECT_LIST ) || treeToken.type().equals( ParserTokenType.TRIPLE ) ) { + continue; + } + final int tokenLine = treeToken.location().fromLine(); + final int tokenColumn = treeToken.location().fromColumn(); + + if ( tokenLine == originatingLine && tokenColumn == originatingColumn ) { + exactMatch = treeToken; + break; + } + + // Calculate Manhattan distance for nearest match + final long distance = Math.abs( tokenLine - originatingLine ) * 1000 + Math.abs( tokenColumn - originatingColumn ); + + if ( distance < minDistance ) { + minDistance = distance; + nearestMatch = treeToken; + } + } + + return exactMatch != null ? exactMatch : nearestMatch; + } + + /** + * Finds the matching Tree-sitter token for a given Jena token based on line and column position. + * If no exact match is found, returns the nearest token. + * + * @param token the Jena token to find a match for + * @return the matching Tree-sitter token, or null if syntax tree is not available or no tokens + * found + */ + private TurtleSyntaxTree.@Nullable Token findMatchingTreeSitterToken( final Token token ) { + return findMatchingTreeSitterToken( token.getLine(), token.getColumn() ); + } + @Override public Node create( final Node currentGraph, final Token token ) { try { final Node node = parserProfile.create( currentGraph, token ); - TokenRegistry.put( node, new SmartToken( token ) ); + final TurtleSyntaxTree.@Nullable Token treeSitterToken = findMatchingTreeSitterToken( token ); + final SmartToken smartToken = treeSitterToken == null + ? new SmartToken( token ) + : new SmartToken( treeSitterToken ); + TokenRegistry.put( node, smartToken ); return node; } catch ( final ValueParsingException exception ) { exception.setLine( token.getLine() ); @@ -90,7 +161,12 @@ public void setBaseIRI( final String baseIri ) { @Override public Triple createTriple( final Node subject, final Node predicate, final Node object, final long line, final long col ) { - return parserProfile.createTriple( subject, predicate, object, line, col ); + final Triple triple = parserProfile.createTriple( subject, predicate, object, line, col ); + final TurtleSyntaxTree.@Nullable Token treeSitterToken = findMatchingTreeSitterToken( line, col ); + if ( treeSitterToken != null ) { + TokenRegistry.put( object, new SmartToken( treeSitterToken ) ); + } + return triple; } @Override diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoader.java b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoader.java index 2369c9682..c93a03d81 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoader.java +++ b/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoader.java @@ -23,32 +23,36 @@ import java.util.Objects; import java.util.stream.Collectors; -import org.eclipse.esmf.aspectmodel.ValueParsingException; -import org.eclipse.esmf.aspectmodel.resolver.exceptions.ParserException; -import org.eclipse.esmf.aspectmodel.resolver.parser.ReaderRiotTurtle; -import org.eclipse.esmf.metamodel.datatype.SammXsdType; - -import io.vavr.control.Try; +import org.apache.commons.io.IOUtils; import org.apache.jena.rdf.model.Model; import org.apache.jena.riot.Lang; import org.apache.jena.riot.RDFParser; import org.apache.jena.riot.RDFParserRegistry; import org.apache.jena.riot.RiotException; import org.apache.jena.riot.system.FactoryRDFStd; +import org.apache.jena.sparql.util.Context; +import org.apache.jena.sparql.util.Symbol; + +import org.eclipse.esmf.aspectmodel.ValueParsingException; +import org.eclipse.esmf.aspectmodel.resolver.exceptions.ParserException; +import org.eclipse.esmf.aspectmodel.resolver.parser.ReaderRiotTurtle; +import org.eclipse.esmf.metamodel.datatype.SammXsdType; +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; + import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import io.vavr.control.Try; + public final class TurtleLoader { private static final Logger LOG = LoggerFactory.getLogger( TurtleLoader.class ); - private static volatile boolean isTurtleRegistered = false; private TurtleLoader() {} public static void init() { - SammXsdType.setupTypeMapping(); registerTurtle(); } @@ -114,6 +118,40 @@ public static Try loadTurtle( @NonNull final String modelContent, @Nullab } } + public static class TreeSitterTurtleSyntaxTreeSymbol extends Symbol { + protected TreeSitterTurtleSyntaxTreeSymbol() { + super( "treesittersyntaxtree" ); + } + } + + public static final Symbol TREE_SITTER_SYNTAX_TREE = new TreeSitterTurtleSyntaxTreeSymbol(); + + public static Try loadTurtle( final TurtleSyntaxTree syntaxTree, final URI location ) { + init(); + final String sourceRepresentation = syntaxTree.sourceRepresentationSupplier().get(); + try ( final InputStream input = IOUtils.toInputStream( sourceRepresentation, StandardCharsets.UTF_8 ) ) { + final Context context = Context.create(); + context.put( new TreeSitterTurtleSyntaxTreeSymbol(), syntaxTree ); + final Model streamModel = RDFParser.create() + .factory( new FactoryRDFStd() ) + .source( input ) + .lang( Lang.TURTLE ) + .context( context ) + .toModel(); + return Try.success( streamModel ); + } catch ( final ValueParsingException exception ) { + // This is thrown by the custom value parsers in SammXsdType + exception.setSourceDocument( sourceRepresentation ); + exception.setSourceLocation( location ); + throw exception; + } catch ( final RiotException exception ) { + // Thrown by Jena for regular syntax errors + return Try.failure( new ParserException( exception, sourceRepresentation, location ) ); + } catch ( final IOException exception ) { + return Try.failure( exception ); + } + } + /** * Loads a Turtle model from a String containing RDF/Turtle * @@ -130,6 +168,7 @@ private static void registerTurtle() { } synchronized ( TurtleLoader.class ) { if ( !isTurtleRegistered ) { + SammXsdType.setupTypeMapping(); RDFParserRegistry.registerLangTriples( Lang.TURTLE, ReaderRiotTurtle.factory ); isTurtleRegistered = true; } diff --git a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoaderTest.java b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoaderTest.java index c320a4733..807c54198 100644 --- a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoaderTest.java +++ b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/loader/AspectModelLoaderTest.java @@ -66,7 +66,7 @@ void testLoadAspectModelsSourceFilesArePresent( final TestAspect testAspect ) { void loadAspectModelWithoutCharacteristicDatatype() { assertThatThrownBy( () -> TestResources.load( InvalidTestAspect.INVALID_CHARACTERISTIC_DATATYPE ) ) .isInstanceOf( AspectLoadingException.class ) - .hasMessage( "No datatype is defined on the Characteristic instance 'Characteristic1: '." ); + .hasMessage( "No datatype is defined on the Characteristic instance 'Characteristic1'." ); } @Test diff --git a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoaderTest.java b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoaderTest.java index f4af86f27..4d2b36a7a 100644 --- a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoaderTest.java +++ b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/aspectmodel/resolver/services/TurtleLoaderTest.java @@ -20,16 +20,26 @@ import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; +import java.net.URI; import java.nio.charset.StandardCharsets; -import org.eclipse.esmf.aspectmodel.resolver.exceptions.ParserException; - -import io.vavr.control.Try; import org.apache.jena.rdf.model.Model; import org.apache.jena.rdf.model.ModelFactory; import org.apache.jena.riot.RDFLanguages; import org.apache.jena.riot.RiotException; + +import org.eclipse.esmf.aspectmodel.resolver.exceptions.ParserException; +import org.eclipse.esmf.test.InvalidTestAspect; +import org.eclipse.esmf.test.TestAspect; +import org.eclipse.esmf.test.TestResources; +import org.eclipse.esmf.treesitterturtle.TreeSitterTurtle; +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; + import org.junit.jupiter.api.Test; +import org.treesitter.TSParser; +import org.treesitter.TSTree; + +import io.vavr.control.Try; public class TurtleLoaderTest { private static final String MODEL = """ @@ -57,4 +67,40 @@ void jenaReaderSucceedsWhenPrefixIsNotDefined() throws IOException { .hasMessageContaining( "[line: 2, col: 13] Undefined prefix: aPrefix" ); } } + + @Test + void loadModelUsingTreeSitter() throws IOException { + final String turtle = + new String( TestResources.testModelSource( TestAspect.ASPECT_WITH_BINARY ).readAllBytes(), StandardCharsets.UTF_8 ); + try ( final TSParser parser = new TSParser() ) { + parser.setLanguage( new TreeSitterTurtle() ); + final TSTree tsTree = parser.parseString( null, turtle ); + final TurtleSyntaxTree turtleSyntaxTree = TurtleSyntaxTree.fromConcreteSyntaxTree( tsTree, () -> turtle, + new TurtleSyntaxTree.StringTokenProvider( turtle ) ); + assertThatCode( () -> { + final Try tryModel = TurtleLoader.loadTurtle( turtleSyntaxTree, buildArtificialUri( turtle, "model" ) ); + assertThat( tryModel.isFailure() ).isFalse(); + final Model model = tryModel.get(); + assertThat( model.listStatements().toList() ).isNotEmpty(); + } ).doesNotThrowAnyException(); + } + } + + @Test + void loadModelWithSyntaxErrorUsingTreeSitter() throws IOException { + final String turtle = + new String( TestResources.testModelSource( InvalidTestAspect.INVALID_SYNTAX ).readAllBytes(), StandardCharsets.UTF_8 ); + try ( final TSParser parser = new TSParser() ) { + parser.setLanguage( new TreeSitterTurtle() ); + final TSTree tsTree = parser.parseString( null, turtle ); + final TurtleSyntaxTree turtleSyntaxTree = TurtleSyntaxTree.fromConcreteSyntaxTree( tsTree, () -> turtle, + new TurtleSyntaxTree.StringTokenProvider( turtle ) ); + final Try tryModel = TurtleLoader.loadTurtle( turtleSyntaxTree, buildArtificialUri( turtle, "model" ) ); + assertThat( tryModel.isFailure() ).isTrue(); + } + } + + private static URI buildArtificialUri( final Object object, final String objectType ) { + return URI.create( "inmemory:%s:%s".formatted( objectType, object.hashCode() ) ); + } } diff --git a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/test/TestResources.java b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/test/TestResources.java index 75456bfc7..20566c1ae 100644 --- a/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/test/TestResources.java +++ b/core/esmf-aspect-meta-model-java/src/test/java/org/eclipse/esmf/test/TestResources.java @@ -23,6 +23,13 @@ import org.eclipse.esmf.samm.KnownVersion; public class TestResources { + @SuppressWarnings( "DataFlowIssue" ) + public static InputStream testModelSource( final TestAspect model ) { + final String path = String.format( "valid/%s/%s/%s.ttl", model.getUrn().getNamespaceMainPart(), model.getUrn().getVersion(), + model.getName() ); + return TestResources.class.getClassLoader().getResourceAsStream( path ); + } + public static AspectModel load( final TestAspect model ) { final KnownVersion metaModelVersion = KnownVersion.getLatest(); final String path = String.format( "valid/%s/%s/%s.ttl", model.getUrn().getNamespaceMainPart(), model.getUrn().getVersion(), @@ -49,6 +56,13 @@ public static AspectModel load( final OrderingTestAspect model ) { return new AspectModelLoader( testModelsResolutionStrategy ).load( inputStream, URI.create( "testmodel:" + path ) ); } + @SuppressWarnings( "DataFlowIssue" ) + public static InputStream testModelSource( final InvalidTestAspect model ) { + final String path = String.format( "invalid/%s/%s/%s.ttl", model.getUrn().getNamespaceMainPart(), model.getUrn().getVersion(), + model.getName() ); + return TestResources.class.getClassLoader().getResourceAsStream( path ); + } + public static AspectModel load( final InvalidTestAspect model ) { final KnownVersion metaModelVersion = KnownVersion.getLatest(); final String path = String.format( "invalid/%s/%s/%s.ttl", model.getUrn().getNamespaceMainPart(), model.getUrn().getVersion(), diff --git a/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/openapi/AspectModelOpenApiGeneratorTest.java b/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/openapi/AspectModelOpenApiGeneratorTest.java index c6cb3f0f1..dd4e7f2a4 100644 --- a/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/openapi/AspectModelOpenApiGeneratorTest.java +++ b/core/esmf-aspect-model-document-generators/src/test/java/org/eclipse/esmf/aspectmodel/generator/openapi/AspectModelOpenApiGeneratorTest.java @@ -15,6 +15,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assumptions.assumeThat; import java.io.IOException; import java.io.InputStream; @@ -26,6 +27,9 @@ import java.util.Locale; import java.util.Map; +import org.apache.commons.io.IOUtils; +import org.assertj.core.api.InstanceOfAssertFactories; + import org.eclipse.esmf.aspectmodel.generator.AbstractSchemaArtifact; import org.eclipse.esmf.aspectmodel.generator.jsonschema.AspectModelJsonSchemaGenerator; import org.eclipse.esmf.metamodel.Aspect; @@ -33,9 +37,13 @@ import org.eclipse.esmf.test.TestAspect; import org.eclipse.esmf.test.TestResources; -import ch.qos.logback.classic.Logger; -import ch.qos.logback.classic.spi.ILoggingEvent; -import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.Execution; +import org.junit.jupiter.api.parallel.ExecutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.slf4j.LoggerFactory; + import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonToken; import com.fasterxml.jackson.databind.JsonNode; @@ -50,6 +58,10 @@ import com.jayway.jsonpath.DocumentContext; import com.jayway.jsonpath.JsonPath; import com.jayway.jsonpath.Option; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.read.ListAppender; import io.swagger.parser.OpenAPIParser; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; @@ -59,14 +71,6 @@ import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.parser.core.models.SwaggerParseResult; -import org.apache.commons.io.IOUtils; -import org.assertj.core.api.InstanceOfAssertFactories; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.parallel.Execution; -import org.junit.jupiter.api.parallel.ExecutionMode; -import org.junit.jupiter.params.ParameterizedTest; -import org.junit.jupiter.params.provider.EnumSource; -import org.slf4j.LoggerFactory; class AspectModelOpenApiGeneratorTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); @@ -301,7 +305,9 @@ void testValidTemplate() throws IOException { @Test void testInValidParameterName() throws IOException { final ListAppender logAppender = new ListAppender<>(); - final Logger logger = (Logger) LoggerFactory.getLogger( AspectModelOpenApiGenerator.class ); + final org.slf4j.Logger theLogger = LoggerFactory.getLogger( AspectModelOpenApiGenerator.class ); + assumeThat( theLogger ).isInstanceOf( ch.qos.logback.classic.Logger.class ); + final Logger logger = (Logger) theLogger; logger.addAppender( logAppender ); logAppender.start(); final Aspect aspect = TestResources.load( TestAspect.ASPECT_WITHOUT_SEE_ATTRIBUTE ).aspect(); diff --git a/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GitHubModelSource.java b/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GitHubModelSource.java index ea37b8b54..cda29b1c2 100644 --- a/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GitHubModelSource.java +++ b/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GitHubModelSource.java @@ -29,7 +29,7 @@ import org.eclipse.esmf.aspectmodel.AspectModelFile; import org.eclipse.esmf.aspectmodel.resolver.AspectModelFileLoader; -import org.eclipse.esmf.aspectmodel.resolver.Download; +import org.eclipse.esmf.util.download.Download; import org.eclipse.esmf.aspectmodel.resolver.GithubRepository; import org.eclipse.esmf.aspectmodel.resolver.ModelSource; import org.eclipse.esmf.aspectmodel.resolver.modelfile.RawAspectModelFile; diff --git a/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GithubModelSourceConfig.java b/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GithubModelSourceConfig.java index b68e601ac..1c9004643 100644 --- a/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GithubModelSourceConfig.java +++ b/core/esmf-aspect-model-github-resolver/src/main/java/org/eclipse/esmf/aspectmodel/resolver/github/GithubModelSourceConfig.java @@ -16,7 +16,7 @@ import java.util.Optional; import org.eclipse.esmf.aspectmodel.resolver.GithubRepository; -import org.eclipse.esmf.aspectmodel.resolver.ProxyConfig; +import org.eclipse.esmf.util.download.ProxyConfig; import io.soabase.recordbuilder.core.RecordBuilder; diff --git a/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/violation/Violation.java b/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/violation/Violation.java index ba76c0166..c1dab3513 100644 --- a/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/violation/Violation.java +++ b/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/shacl/violation/Violation.java @@ -17,6 +17,8 @@ import java.util.List; import java.util.Optional; +import org.apache.jena.rdf.model.RDFNode; + import org.eclipse.esmf.aspectmodel.AspectModelFile; import org.eclipse.esmf.aspectmodel.resolver.parser.SmartToken; import org.eclipse.esmf.aspectmodel.resolver.parser.TokenRegistry; @@ -27,7 +29,7 @@ import org.eclipse.esmf.aspectmodel.validation.ProcessingViolation; import org.eclipse.esmf.aspectmodel.validation.RegularExpressionConstraintViolation; -import org.apache.jena.rdf.model.RDFNode; +import org.jspecify.annotations.Nullable; /** * Represents a single violation raised by one or more SHACL shapes against an RDF model. A @@ -59,8 +61,8 @@ public interface Violation { /** * The RDF node this violation focusses on */ - default RDFNode highlight() { - return context().element(); + default @Nullable RDFNode highlight() { + return context() == null ? null : context().element(); } /** diff --git a/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/validation/ProcessingViolation.java b/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/validation/ProcessingViolation.java index 5ca9f32d5..c26fbb65f 100644 --- a/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/validation/ProcessingViolation.java +++ b/core/esmf-aspect-model-validator/src/main/java/org/eclipse/esmf/aspectmodel/validation/ProcessingViolation.java @@ -13,9 +13,14 @@ package org.eclipse.esmf.aspectmodel.validation; +import org.apache.jena.rdf.model.RDFNode; + +import org.eclipse.esmf.aspectmodel.AspectLoadingException; import org.eclipse.esmf.aspectmodel.shacl.violation.EvaluationContext; import org.eclipse.esmf.aspectmodel.shacl.violation.Violation; +import org.jspecify.annotations.Nullable; + /** * Meta violation: The validation was unsuccessful, for example because the model could not be * loaded or not be resolved @@ -42,6 +47,11 @@ public String message() { return violationSpecificMessage(); } + @Override + public @Nullable RDFNode highlight() { + return cause instanceof final AspectLoadingException aspectLoadingException ? aspectLoadingException.highlightElement() : null; + } + @Override public T accept( final Visitor visitor ) { return visitor.visitProcessingViolation( this ); diff --git a/core/esmf-tree-sitter-turtle/.gitignore b/core/esmf-tree-sitter-turtle/.gitignore new file mode 100644 index 000000000..eb4c9a6ad --- /dev/null +++ b/core/esmf-tree-sitter-turtle/.gitignore @@ -0,0 +1,18 @@ +src/main/c/parser.c +src/main/c/grammar.json +src/main/c/node-types.json +src/main/c/tree_sitter/* +src/main/c/include/jni.h +src/main/c/include/linux/jni_md.h +src/main/c/include/linux/jawt_md.h +src/main/c/include/macos/jni_md.h +src/main/c/include/macos/jawt_md.h +src/main/c/include/windows/jni_md.h +src/main/c/include/windows/jawt_md.h +src/main/c/include/windows/bridge/AccessBridgeCallbacks.h +src/main/c/include/windows/bridge/AccessBridgeCalls.h +src/main/c/include/windows/bridge/AccessBridgePackages.h +src/main/js/grammar.js +src/main/js/bindings +src/main/js/js +.zig-cache diff --git a/core/esmf-tree-sitter-turtle/README.md b/core/esmf-tree-sitter-turtle/README.md new file mode 100644 index 000000000..18d9528c4 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/README.md @@ -0,0 +1,22 @@ +# ESMF tree-sitter-turtle + +This module provides a parser for RDF/Turtle, to be used with the [tree-sitter Java binding](https://github.com/bonede/tree-sitter-ng). +First, native C code is generated from the grammar defined in src/main/js/grammar.js using the tree-sitter CLI. This C code is then compiled +to native libraries (.so/.dylib/.dll) for Linux/MacOS/Windows using the [Zig compiler](https://ziglang.org/); since it has built-in +cross-platform compilation capabilities. As the last step, a Java class makes the library available to Java code via JNI. +The Java code that performs the download and execution of Zig is compiled and executed during the build, but is not part of the +final module artifact. + +The build works as follows, in chronological order: + +| Maven lifecycle phase | Plugin | Maven Goal | Description | +|-----------------------|-----------------------|------------|-------------| +| initialize | frontend-maven-plugin | install-node-and-npm | Locally install node and npm | +| initialize | fronted-maven-plugin | npm | Use npm to install tree-sitter CLI locally | +| generate-sources | exec-maven-plugin | exec | Execute tree-sitter CLI to generate Turtle parser C code | +| generate-sources | maven-compiler-plugin | compile | Compile DownloadZig and NativeCompile classes (build time code) | +| process-sources | exec-maven-plugin | java | Execute DownloadZig class, to download and extract Zig release | +| compile | maven-compiler-plugin | compile | Compile regular Java binding class (TreeSitterTurtle) | +| compile | exec-maven-plugin | java | Execute NativeCompile class, to use Zig compiler to create native libraries from Turtle parser C code | + +At the end of the build, platform-specific native libs are present in target/classes/libs. diff --git a/core/esmf-tree-sitter-turtle/pom.xml b/core/esmf-tree-sitter-turtle/pom.xml new file mode 100644 index 000000000..987e61eb3 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/pom.xml @@ -0,0 +1,472 @@ + + + + + + org.eclipse.esmf + esmf-sdk-parent + DEV-SNAPSHOT + ../../pom.xml + + 4.0.0 + + esmf-tree-sitter-turtle + ESMF Tree Sitter Turtle + jar + + + + 1.12.1 + 0.2.1 + 1.9.0 + + 11.2.0 + 22.14.0 + 0.15.2 + 0.11 + + jdk-25+36 + + + 0.25.10 + tree-sitter-cli + 0.26.6 + + + ${project.build.directory}/node/node + ${project.basedir}/.zig-cache + + c + js + + + + + org.eclipse.esmf + esmf-util + compile + + + io.github.bonede + tree-sitter + ${tree-sitter.version} + + + org.apache.commons + commons-compress + compile + + + org.tukaani + xz + compile + + + com.fasterxml.jackson.core + jackson-databind + compile + + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + org.slf4j + slf4j-nop + test + + + + + + + + com.github.eirslett + frontend-maven-plugin + ${frontend-maven-plugin.version} + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-generated-sources + initialize + + add-source + + + + ${generated-sources}/main/java + + + + + add-buildtime-sources + initialize + + add-source + + + + ${build-time-sources}/main/java + + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + **/buildtime/*.class + + + + + + org.apache.maven.plugins + maven-clean-plugin + + + + ${generated-sources} + + + + + + + org.apache.maven.plugins + maven-resources-plugin + + + copy-npm-config + initialize + + copy-resources + + + + + ${project.basedir}/../.. + + package-lock.json + package.json + + + + ${project.build.directory} + + + + + + + com.googlecode.maven-download-plugin + download-maven-plugin + ${download-maven-plugin.version} + + + download-jni-header + initialize + + wget + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/java.base/share/native/include/jni.h + ${project.basedir}/src/main/c/include + true + + + + download-jni-headers-unix + initialize + + wget + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/java.base/unix/native/include/jni_md.h + ${project.basedir}/src/main/c/include/linux + true + + + + download-jni-headers-macos + initialize + + wget + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/java.base/unix/native/include/jni_md.h + ${project.basedir}/src/main/c/include/macos + true + + + + download-jni-headers-windows + initialize + + wget + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/java.base/windows/native/include/jni_md.h + ${project.basedir}/src/main/c/include/windows + true + + + + download-jawt-headers-unix + initialize + + wget + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/java.desktop/unix/native/include/jawt_md.h + ${project.basedir}/src/main/c/include/linux + true + + + + download-jawt-headers-macos + initialize + + wget + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/java.desktop/unix/native/include/jawt_md.h + ${project.basedir}/src/main/c/include/macos + true + + + + download-jawt-headers-windows + initialize + + wget + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/java.desktop/windows/native/include/jawt_md.h + ${project.basedir}/src/main/c/include/windows + true + + + + download-accessbridge-callbacks-windows + initialize + + wget + + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/jdk.accessibility/windows/native/include/bridge/AccessBridgeCallbacks.h + + ${project.basedir}/src/main/c/include/windows/bridge + true + + + + download-accessbridge-calls-windows + initialize + + wget + + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/jdk.accessibility/windows/native/include/bridge/AccessBridgeCalls.h + + ${project.basedir}/src/main/c/include/windows/bridge + true + + + + download-accessbridge-packages-windows + initialize + + wget + + + + https://raw.githubusercontent.com/openjdk/jdk/refs/tags/${openjdk.tag}/src/jdk.accessibility/windows/native/include/bridge/AccessBridgePackages.h + + ${project.basedir}/src/main/c/include/windows/bridge + true + + + + + + + com.github.eirslett + frontend-maven-plugin + + + install-node-and-npm + initialize + + install-node-and-npm + + + v${node.version} + ${npm.version} + ${project.build.directory} + + + + install-tree-sitter-cli + initialize + + npm + + + ${project.build.directory} + ci --prefix . + + + + + + + org.codehaus.mojo + exec-maven-plugin + + + download-turtle-grammar + generate-sources + + java + + + org.eclipse.esmf.buildtime.DownloadTurtleGrammar + + + ${project.basedir}/src/main/${js.source.directory} + + + + + tree-sitter-generate + generate-sources + + exec + + + ${node.path} + ${project.basedir}/src/main/js + + ${project.build.directory}/node_modules/tree-sitter-cli/cli.js generate grammar.js --output ../${c.source.directory} + + + + + download-zig + process-sources + + java + + + org.eclipse.esmf.buildtime.DownloadZig + + + ${zig-cache.path} + + ${zig.version} + + ${minisign.version} + + + ${settings.localRepository}/org/tukaani/xz/${xz.version}/xz-${xz.version}.jar + + + + + native-compile + compile + + java + + + org.eclipse.esmf.buildtime.NativeCompile + + + ${zig-cache.path} + + ${zig.version} + + ${project.build.outputDirectory}/lib + + ${project.basedir}/src/main/${c.source.directory} + + + + + generate-parser-token-types + process-sources + + java + + + org.eclipse.esmf.buildtime.GenerateParserTokenType + + + ${project.basedir}/src/main/${c.source.directory}/node-types.json + + ${generated-sources} + + + + + + + + maven-compiler-plugin + + + -h + ${project.build.directory}/generated-include + + + + + compile-build-time-code + initialize + + compile + + + ${build-time-sources} + + + + + + + diff --git a/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/BuildTimeException.java b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/BuildTimeException.java new file mode 100644 index 000000000..8a05a0e09 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/BuildTimeException.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.buildtime; + +public class BuildTimeException extends RuntimeException { + public BuildTimeException( final String message ) { + super( message ); + } + + public BuildTimeException( final Exception exception ) { + super( exception ); + } +} diff --git a/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/BuildTimeTool.java b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/BuildTimeTool.java new file mode 100644 index 000000000..b7e89c9a9 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/BuildTimeTool.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.buildtime; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Duration; + +import org.eclipse.esmf.util.download.Download; +import org.eclipse.esmf.util.download.ProxyConfig; + +import org.jspecify.annotations.Nullable; + +import jakarta.xml.bind.annotation.adapters.HexBinaryAdapter; + +public abstract class BuildTimeTool { + private final @Nullable Path cacheLocation; + protected final ProxyConfig proxyConfig; + + public BuildTimeTool() { + this( null ); + } + + public BuildTimeTool( final @Nullable Path cacheLocation ) { + this.cacheLocation = cacheLocation; + proxyConfig = ProxyConfig.detectProxySettings(); + } + + protected void mkdir( final Path path ) { + try { + Files.createDirectories( path ); + } catch ( final IOException exception ) { + throw new BuildTimeException( exception ); + } + } + + protected URL url( final String url ) { + try { + return URI.create( url ).toURL(); + } catch ( final MalformedURLException exception ) { + throw new BuildTimeException( exception ); + } + } + + protected Path cacheLocation() { + if ( cacheLocation == null ) { + throw new BuildTimeException( "Cache was not initialized" ); + } + return cacheLocation; + } + + protected File getOrDownloadFile( final URL location ) { + final Path filePath = Paths.get( location.getPath() ); + final String filename = filePath.getName( filePath.getNameCount() - 1 ).toString(); + final File targetFile = cacheLocation().resolve( filename ).toFile(); + if ( targetFile.exists() ) { + return targetFile; + } + + final Download.Config config = new Download.Config( proxyConfig, Duration.ofSeconds( 3L ) ); + System.out.println( "Downloading " + location + " to " + targetFile.getName() + "..." ); + return new Download( config ).downloadFile( location, targetFile ); + } + + protected String sha1( final File file ) { + try ( final InputStream input = new FileInputStream( file ); + final DigestInputStream digestStream = new DigestInputStream( input, MessageDigest.getInstance( "SHA-1" ) ) ) { + digestStream.readAllBytes(); + return new HexBinaryAdapter().marshal( digestStream.getMessageDigest().digest() ).toLowerCase(); + } catch ( final NoSuchAlgorithmException | IOException exception ) { + throw new BuildTimeException( "Could not calculate SHA1 sum for " + file ); + } + } + + protected void write( final File outputFile, final String fileContent ) { + try ( final OutputStream outputStream = new FileOutputStream( outputFile ) ) { + outputStream.write( fileContent.getBytes( StandardCharsets.UTF_8 ) ); + } catch ( final IOException exception ) { + throw new RuntimeException( "Could not write source code file " + outputFile, exception ); + } + System.out.println( "Written " + outputFile ); + } +} diff --git a/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/DownloadTurtleGrammar.java b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/DownloadTurtleGrammar.java new file mode 100644 index 000000000..3e56b6acc --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/DownloadTurtleGrammar.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.buildtime; + +import java.io.File; +import java.net.URL; +import java.nio.file.Path; + +public class DownloadTurtleGrammar extends BuildTimeTool { + private static final String GRAMMAR_JS_SHA1 = "6146fa24f0dd601a34025ef3618c3cd4d5dfdb4b"; + + public DownloadTurtleGrammar( final Path cacheLocation ) { + super( cacheLocation ); + } + + public void downloadTurtleGrammar() { + final URL url = url( "https://raw.githubusercontent.com/GordianDziwis/tree-sitter-turtle/refs/heads/main/grammar.js" ); + final File grammarJs = getOrDownloadFile( url ); + if ( !grammarJs.exists() ) { + throw new BuildTimeException( "Downloading Turtle grammar failed" ); + } + final String sha1 = sha1( grammarJs ); + if ( !sha1.equals( GRAMMAR_JS_SHA1 ) ) { + throw new BuildTimeException( "Invalid SHA1 sum for grammar.js. Expected: " + GRAMMAR_JS_SHA1 + ", found: " + sha1 ); + } + } + + static void main( final String[] args ) { + final Path cacheLocation = Path.of( args[0] ); + final DownloadTurtleGrammar downloadTurtleGrammar = new DownloadTurtleGrammar( cacheLocation ); + downloadTurtleGrammar.downloadTurtleGrammar(); + } +} diff --git a/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/DownloadZig.java b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/DownloadZig.java new file mode 100644 index 000000000..f0832d849 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/DownloadZig.java @@ -0,0 +1,242 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.buildtime; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; +import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream; +import org.apache.commons.compress.compressors.xz.XZCompressorInputStream; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.IOUtils; + +import org.eclipse.esmf.util.process.BinaryLauncher; +import org.eclipse.esmf.util.process.ProcessLauncher; + +public class DownloadZig extends ZigContext { + private static final String ZIG_PUB_KEY = "RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U"; + private final URL mirrorsListUrl; + + private final String minisignVersion; + + public DownloadZig( final Path cacheLocation, final String zigVersion, final String minisignVersion ) { + super( cacheLocation, zigVersion ); + this.minisignVersion = minisignVersion; + mirrorsListUrl = url( "https://ziglang.org/download/community-mirrors.txt" ); + } + + private String zigReleaseFileName() { + return switch ( currentOs ) { + case WINDOWS -> "zig-x86_64-windows-%s.zip".formatted( zigVersion ); + case MACOS, LINUX -> "zig-%s-%s-%s.tar.xz".formatted( currentArchitecture, currentOs, zigVersion ); + }; + } + + private String minisignReleaseFileName() { + return switch ( currentOs ) { + case WINDOWS -> "minisign-%s-win64.zip".formatted( minisignVersion ); + case MACOS -> "minisign-%s-macos.zip".formatted( minisignVersion ); + case LINUX -> "minisign-%s-linux.tar.gz".formatted( minisignVersion ); + }; + } + + private Path minisignExecutablePath() { + return switch ( currentOs ) { + case WINDOWS -> Path.of( "minisign-win64", "minisign.exe" ); + case MACOS -> Path.of( "minisign" ); + case LINUX -> Path.of( "minisign-linux", currentArchitecture.toString(), "minisign" ); + }; + } + + private List zigMirrorUrls() { + final File mirrors = getOrDownloadFile( mirrorsListUrl ); + try { + return Files.readAllLines( mirrors.toPath() ).stream().collect( Collectors.collectingAndThen( Collectors.toList(), collected -> { + Collections.shuffle( collected ); + return collected; + } ) ); + } catch ( final IOException exception ) { + throw new BuildTimeException( exception ); + } + } + + private File getOrDownloadFileFromMirror( final List mirrorUrls, final File outputFile ) { + if ( outputFile.exists() ) { + return outputFile; + } + final List triedLocations = new ArrayList<>(); + for ( final String mirrorUrl : mirrorUrls ) { + try { + final URL url = url( mirrorUrl + "/" + outputFile.getName() ); + triedLocations.add( url.toString() ); + return getOrDownloadFile( url ); + } catch ( final Exception exception ) { + // try the next mirror + } + } + throw new BuildTimeException( "Could not retrieve " + outputFile.getName() + " from any known mirror. Tried:" + + triedLocations.stream().map( url -> " - " + url ) + .collect( Collectors.joining( "\n" ) ) ); + } + + private void extractZip( final File zipFile, final Path outputDir ) { + if ( outputDir.toFile().exists() ) { + return; + } else { + mkdir( outputDir ); + } + + try ( final ZipInputStream zipInputStream = new ZipInputStream( new FileInputStream( zipFile ) ) ) { + for ( ZipEntry entry = zipInputStream.getNextEntry(); entry != null; entry = zipInputStream.getNextEntry() ) { + final Path output = outputDir.resolve( entry.getName() ); + if ( entry.isDirectory() ) { + mkdir( output ); + } else { + try ( final FileOutputStream outputStream = new FileOutputStream( output.toFile() ) ) { + IOUtils.copy( zipInputStream, outputStream ); + } + } + } + } catch ( final IOException exception ) { + throw new BuildTimeException( exception ); + } + } + + private void extractTar( final File tarFile, final Path outputDir ) { + if ( outputDir.toFile().exists() ) { + return; + } else { + mkdir( outputDir ); + } + + final String name = tarFile.getName().toLowerCase(); + if ( !name.endsWith( ".tar.gz" ) && !name.endsWith( ".tar.xz" ) && !name.endsWith( ".tgz" ) ) { + throw new BuildTimeException( "Only .tar.gz, .tgz, and .tar.xz files are supported" ); + } + + try ( final FileInputStream fileInputStream = new FileInputStream( tarFile ) ) { + final InputStream compressorInputStream = name.endsWith( ".tar.xz" ) + ? new XZCompressorInputStream( fileInputStream ) + : new GzipCompressorInputStream( fileInputStream ); + try ( final TarArchiveInputStream tarInputStream = new TarArchiveInputStream( compressorInputStream ) ) { + for ( TarArchiveEntry entry = tarInputStream.getNextEntry(); entry != null; entry = tarInputStream.getNextEntry() ) { + final Path output = outputDir.resolve( entry.getName() ); + if ( entry.isDirectory() ) { + mkdir( output ); + } else { + try ( final FileOutputStream outputStream = new FileOutputStream( output.toFile() ) ) { + IOUtils.copy( tarInputStream, outputStream ); + } + } + } + } + + } catch ( final IOException exception ) { + throw new BuildTimeException( exception ); + } + } + + private void extractArchive( final File archiveFile, final Path targetDirectory ) { + if ( archiveFile.getName().endsWith( ".zip" ) ) { + extractZip( archiveFile, targetDirectory ); + } else if ( archiveFile.getName().endsWith( ".tar.xz" ) || archiveFile.getName().endsWith( ".tar.gz" ) + || archiveFile.getName().endsWith( ".tgz" ) ) { + extractTar( archiveFile, targetDirectory ); + } else { + throw new BuildTimeException( "Do not know how to extract archive: " + archiveFile ); + } + } + + private File downloadAndExtractMinisign() { + final Path minisignDir = cacheLocation().resolve( "minisign" ); + final File minisignExe = minisignDir.resolve( minisignExecutablePath() ).toFile(); + if ( minisignExe.exists() ) { + return minisignExe; + } + final File minisignArchive = getOrDownloadFile( url( "https://github.com/jedisct1/minisign/releases/download/" + minisignVersion + + "/" + minisignReleaseFileName() ) ); + extractArchive( minisignArchive, minisignDir ); + if ( currentOs == OperatingSystem.LINUX || currentOs == OperatingSystem.MACOS ) { + minisignExe.setExecutable( true ); + } + try { + FileUtils.delete( minisignArchive ); + } catch ( final IOException exception ) { + // ignore, since it's only the cache + } + return minisignExe; + } + + private File downloadAndExtractZig( final File minisignExe ) { + final File zigExe = zigExe(); + if ( zigExe.exists() ) { + return zigExe; + } + + final List mirrors = zigMirrorUrls(); + final String fileName = zigReleaseFileName(); + final File signatureOutputFile = cacheLocation().resolve( fileName + ".minisig" ).toFile(); + final File zigReleaseArchive = getOrDownloadFileFromMirror( mirrors, cacheLocation().resolve( fileName ).toFile() ); + getOrDownloadFileFromMirror( mirrors, signatureOutputFile ); + validateZigReleaseSignature( minisignExe, zigReleaseArchive ); + + if ( !zigDir().toFile().exists() ) { + extractArchive( zigReleaseArchive, zigDir() ); + if ( currentOs == OperatingSystem.LINUX || currentOs == OperatingSystem.MACOS ) { + zigExe.setExecutable( true ); + } + } + try { + FileUtils.delete( zigReleaseArchive ); + FileUtils.delete( signatureOutputFile ); + } catch ( final IOException exception ) { + // ignore, since it's only the cache + } + return zigExe; + } + + private void validateZigReleaseSignature( final File minisignExe, final File zigReleaseArchive ) { + final ProcessLauncher.ExecutionResult minisignExecutionResult = new BinaryLauncher( minisignExe ).apply( + List.of( "-qVm", zigReleaseArchive.getAbsolutePath(), "-P", ZIG_PUB_KEY ), Optional.empty(), + zigReleaseArchive.getParentFile() ); + if ( minisignExecutionResult.exitStatus() != 0 ) { + throw new BuildTimeException( "Signature of " + zigReleaseArchive + " does not match" ); + } + } + + static void main( final String[] args ) { + final Path cacheLocation = Path.of( args[0] ); + final String zigVersion = args[1]; + final String minisignVersion = args[2]; + + final DownloadZig downloadZig = new DownloadZig( cacheLocation, zigVersion, minisignVersion ); + final File minisignExe = downloadZig.downloadAndExtractMinisign(); + downloadZig.downloadAndExtractZig( minisignExe ); + } +} diff --git a/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/GenerateParserTokenType.java b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/GenerateParserTokenType.java new file mode 100644 index 000000000..2553d2736 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/GenerateParserTokenType.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.buildtime; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Path; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.Streams; + +public class GenerateParserTokenType extends BuildTimeTool { + private static final String CLASS_NAME = "ParserTokenType"; + + public void generate( final File nodeTypesJson, final Path srcGen ) throws IOException { + final Path packagePath = srcGen.resolve( "main" ).resolve( "java" ) + .resolve( "org" ).resolve( "eclipse" ).resolve( "esmf" ).resolve( "treesitterturtle" ); + mkdir( packagePath ); + + final ObjectMapper objectMapper = new ObjectMapper(); + final Set tokens = getNodeTypes( objectMapper.readTree( nodeTypesJson ) ) + .map( String::toLowerCase ) + .collect( Collectors.toSet() ); + final String declarations = javaConstantsForTokens( tokens ).entrySet().stream() + .sorted( Map.Entry.comparingByKey() ) + .map( entry -> " public static final String %s = \"%s\";%n".formatted( entry.getKey(), entry.getValue() ) ) + .collect( Collectors.joining() ); + final String classContent = """ + package org.eclipse.esmf.treesitterturtle; + + import javax.annotation.processing.Generated; + + @Generated( "org.eclipse.esmf.buildtime.GenerateParserTokenType" ) + public class %s { + %s + } + """.formatted( CLASS_NAME, declarations ); + write( packagePath.resolve( CLASS_NAME + ".java" ).toFile(), classContent ); + } + + private Map javaConstantsForTokens( final Collection tokens ) { + return tokens.stream().collect( Collectors.toMap( token -> { + if ( token.length() == 1 && !Character.isLetterOrDigit( token.charAt( 0 ) ) ) { + return "SYMBOL_" + Character.getName( token.charAt( 0 ) ) + .replace( '-', '_' ) + .replace( ' ', '_' ).toUpperCase(); + } + return switch ( token ) { + case "\"\"" -> "SYMBOL_DOUBLE_QUOTE"; + case "\"\"\"" -> "SYMBOL_TRIPLE_QUOTE"; + case "''" -> "SYMBOL_DOUBLE_SINGLE_QUOTE"; + case "'''" -> "SYMBOL_TRIPLE_SINGLE_QUOTE"; + case "@base" -> "AT_BASE"; + case "@prefix" -> "AT_PREFIX"; + case "^^" -> "SYMBOL_DOUBLE_CARET"; + case "_:" -> "BLANK_NODE_PREFIX"; + default -> token.toUpperCase(); + }; + }, token -> switch ( token ) { + case "\"" -> "\\\""; + case "\"\"" -> "\\\"\\\""; + case "\"\"\"" -> "\\\"\\\"\\\""; + default -> token; + } ) ); + } + + private Stream getNodeTypes( final JsonNode node ) { + return node.isArray() + ? Streams.stream( node.elements() ).flatMap( this::getNodeTypes ) + : Optional.ofNullable( node.get( "type" ) ).map( JsonNode::asText ).stream(); + } + + static void main( final String[] args ) { + try { + new GenerateParserTokenType().generate( new File( args[0] ), Path.of( args[1] ) ); + } catch ( final Exception exception ) { + throw new BuildTimeException( exception ); + } + } + +} diff --git a/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/NativeCompile.java b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/NativeCompile.java new file mode 100644 index 000000000..39bf7795b --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/NativeCompile.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.buildtime; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import org.eclipse.esmf.util.process.BinaryLauncher; +import org.eclipse.esmf.util.process.ProcessLauncher; + +public class NativeCompile extends ZigContext { + private static final String LIB_NAME = "tree-sitter-turtle"; + + private final Path libOutputDirectory; + private final Path nativeSourcesDirectory; + + public NativeCompile( final Path cacheLocation, final String zigVersion, final Path libOutputDirectory, + final Path nativeSourcesDirectory ) { + super( cacheLocation, zigVersion ); + this.libOutputDirectory = libOutputDirectory; + this.nativeSourcesDirectory = nativeSourcesDirectory; + } + + private record Target( + OperatingSystem operatingSystem, + Architecture architecture + ) { + public String getTargetName() { + return operatingSystem == OperatingSystem.LINUX + ? "%s-linux-gnu".formatted( architecture.toString() ) + : "%s-%s".formatted( architecture().toString(), operatingSystem().toString() ); + } + } + + private List getTargets() { + return List.of( + new Target( OperatingSystem.WINDOWS, Architecture.X86_64 ), + new Target( OperatingSystem.MACOS, Architecture.X86_64 ), + new Target( OperatingSystem.MACOS, Architecture.AARCH64 ), + new Target( OperatingSystem.LINUX, Architecture.X86_64 ), + new Target( OperatingSystem.LINUX, Architecture.AARCH64 ) ); + } + + private String libNameForTarget( final Target target ) { + return switch ( target.operatingSystem() ) { + case WINDOWS -> "%s-%s.dll".formatted( target.getTargetName(), LIB_NAME ); + case LINUX -> "%s-%s.so".formatted( target.getTargetName(), LIB_NAME ); + case MACOS -> "%s-%s.dylib".formatted( target.getTargetName(), LIB_NAME ); + }; + } + + private void runZig() { + final File zigExe = zigExe(); + + for ( final Target target : getTargets() ) { + final List args = new ArrayList<>(); + final File libFilename = libOutputDirectory.resolve( libNameForTarget( target ) ).toFile(); + + if ( libFilename.exists() ) { + continue; + } + args.add( "c++" ); + args.add( "-g0" ); + args.add( "-fno-sanitize=undefined" ); + args.add( "-fdeclspec" ); + args.add( "-shared" ); + args.add( "-target" ); + args.add( target.getTargetName() ); + args.add( "-I" ); + args.add( zigDir().resolve( "lib" ).resolve( "include" ).toString() ); + args.add( "-I" ); + args.add( nativeSourcesDirectory.toString() ); + args.add( "-I" ); + args.add( nativeSourcesDirectory.resolve( "include" ).toString() ); + args.add( "-I" ); + args.add( nativeSourcesDirectory.resolve( "include" ).resolve( currentOs.toString() ).toString() ); + args.add( "-o" ); + args.add( libFilename.getAbsolutePath() ); + try ( final Stream subpaths = Files.list( nativeSourcesDirectory ) ) { + subpaths.filter( sourceFile -> sourceFile.toFile().isFile() ) + .filter( sourceFile -> sourceFile.getFileName().toString().endsWith( ".c" ) ) + .forEach( sourceFile -> args.add( sourceFile.toString() ) ); + } catch ( final IOException exception ) { + throw new BuildTimeException( exception ); + } + + System.out.println( zigExe().getAbsolutePath() + " " + String.join( " ", args ) ); + mkdir( libOutputDirectory ); + final ProcessLauncher.ExecutionResult zigExecutionResult = + new BinaryLauncher( zigExe ).apply( args, Optional.empty(), libOutputDirectory.toFile() ); + if ( zigExecutionResult.exitStatus() != 0 ) { + System.err.println( zigExecutionResult.stderr() ); + throw new BuildTimeException( "Compilation of native lib failed" ); + } + System.out.println( "Native lib compiled: " + libFilename.getAbsolutePath() ); + } + } + + static void main( final String[] args ) { + final Path cacheLocation = Path.of( args[0] ); + final String zigVersion = args[1]; + final Path libOutputDirectory = Path.of( args[2] ); + final Path nativeSourcesDirectory = Path.of( args[3] ); + final NativeCompile nativeCompile = new NativeCompile( cacheLocation, zigVersion, libOutputDirectory, nativeSourcesDirectory ); + nativeCompile.runZig(); + } +} diff --git a/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/ZigContext.java b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/ZigContext.java new file mode 100644 index 000000000..77bd155ab --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src-buildtime/main/java/org/eclipse/esmf/buildtime/ZigContext.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.buildtime; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import org.apache.commons.lang3.ArchUtils; +import org.apache.commons.lang3.SystemUtils; +import org.apache.commons.lang3.arch.Processor; + +public class ZigContext extends BuildTimeTool { + protected final OperatingSystem currentOs; + protected final Architecture currentArchitecture; + protected final String zigVersion; + + protected enum OperatingSystem { + WINDOWS, MACOS, LINUX; + + @Override + public String toString() { + return super.toString().toLowerCase(); + } + } + + protected enum Architecture { + X86_64, AARCH64; + + @Override + public String toString() { + return super.toString().toLowerCase(); + } + } + + public ZigContext( final Path cacheLocation, final String zigVersion ) { + super( cacheLocation ); + this.zigVersion = zigVersion; + + final Processor processor = ArchUtils.getProcessor(); + if ( processor.isX86() && processor.is64Bit() ) { + currentArchitecture = Architecture.X86_64; + } else if ( processor.isAarch64() && processor.is64Bit() ) { + currentArchitecture = Architecture.AARCH64; + } else { + throw new BuildTimeException( "Unsupported architecture: " + processor.getType().getLabel() + + "/" + processor.getArch().getLabel() ); + } + + if ( SystemUtils.IS_OS_WINDOWS ) { + currentOs = OperatingSystem.WINDOWS; + if ( !processor.isX86() ) { + throw new BuildTimeException( "Unsupported architecture: " + processor.getType().getLabel() + + "/" + processor.getArch().getLabel() ); + } + } else if ( SystemUtils.IS_OS_MAC ) { + currentOs = OperatingSystem.MACOS; + } else if ( SystemUtils.IS_OS_LINUX ) { + currentOs = OperatingSystem.LINUX; + } else { + throw new BuildTimeException( "Unsupported operating system: " + SystemUtils.OS_NAME ); + } + + try { + Files.createDirectories( cacheLocation ); + } catch ( final IOException exception ) { + throw new BuildTimeException( "Could not create cache directory: " + cacheLocation ); + } + } + + protected Path zigDir() { + return cacheLocation().resolve( "zig" ); + } + + protected File zigExe() { + final Path zigExecutablePath = switch ( currentOs ) { + case WINDOWS -> Path.of( "zig-x86_64-windows-" + zigVersion, "zig.exe" ); + case MACOS, LINUX -> Path.of( "zig-%s-%s-%s".formatted( currentArchitecture, currentOs, zigVersion ), "zig" ); + }; + return zigDir().resolve( zigExecutablePath ).toFile(); + } +} diff --git a/core/esmf-tree-sitter-turtle/src/main/c/org_eclipse_esmf_treesitterturtle_TreeSitterTurtle.c b/core/esmf-tree-sitter-turtle/src/main/c/org_eclipse_esmf_treesitterturtle_TreeSitterTurtle.c new file mode 100644 index 000000000..70ccff3d1 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src/main/c/org_eclipse_esmf_treesitterturtle_TreeSitterTurtle.c @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +#include +void *tree_sitter_turtle(); +/* + * Class: org_treesitter_TreeSitterTurtle + * Method: tree_sitter_turtle + * Signature: ()J + */ +JNIEXPORT jlong JNICALL Java_org_eclipse_esmf_treesitterturtle_TreeSitterTurtle_tree_1sitter_1turtle + (JNIEnv *env, jclass clz){ + return (jlong) tree_sitter_turtle(); +} diff --git a/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TreeSitterTurtle.java b/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TreeSitterTurtle.java new file mode 100644 index 000000000..fb861059e --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TreeSitterTurtle.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.treesitterturtle; + +import org.treesitter.TSLanguage; +import org.treesitter.TSParser; +import org.treesitter.utils.NativeUtils; + +/** + * Language definition for RDF/Turtle to be used with {@link TSParser}: + * {@code + * TSParser parser = new TSParser(); + * parser.setLanguage( new TreeSitterTurtle() ); + * } + */ +public class TreeSitterTurtle extends TSLanguage { + static { + NativeUtils.loadLib( "lib/tree-sitter-turtle" ); + } + + private static native long tree_sitter_turtle(); + + public TreeSitterTurtle() { + super( tree_sitter_turtle() ); + } + + private TreeSitterTurtle( final long ptr ) { + super( ptr ); + } + + @Override + public TSLanguage copy() { + return new TreeSitterTurtle( copyPtr() ); + } +} diff --git a/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TreeSitterUtil.java b/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TreeSitterUtil.java new file mode 100644 index 000000000..14f47e26f --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TreeSitterUtil.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.treesitterturtle; + +import org.jspecify.annotations.Nullable; +import org.treesitter.TSNode; +import org.treesitter.TSTree; + +public class TreeSitterUtil { + private TreeSitterUtil() {} + + public static String print( final TSTree tree ) { + return print( tree.getRootNode() ); + } + + public static String print( final TSNode node ) { + final StringBuilder builder = new StringBuilder(); + print( node, builder, 0, null ); + return builder.toString(); + } + + public static String print( final TSTree tree, final TurtleSyntaxTree.TokenProvider tokenProvider ) { + return print( tree.getRootNode(), tokenProvider ); + } + + public static String print( final TSNode node, final TurtleSyntaxTree.TokenProvider tokenProvider ) { + final StringBuilder builder = new StringBuilder(); + print( node, builder, 0, tokenProvider ); + return builder.toString(); + } + + private static void print( final TSNode node, final StringBuilder builder, final int indentLevel, + final TurtleSyntaxTree.@Nullable TokenProvider tokenProvider ) { + builder.repeat( " ", indentLevel ); + builder.append( "- '" ); + builder.append( node.getType() ); + builder.append( "'" ); + if ( node.hasError() ) { + builder.append( " (ERROR)" ); + } else if ( tokenProvider != null && node.getStartPoint().getRow() == node.getEndPoint().getRow() ) { + final TurtleSyntaxTree.Location location = new TurtleSyntaxTree.Location( + node.getStartPoint().getRow(), + node.getStartPoint().getColumn(), + node.getEndPoint().getRow(), + node.getEndPoint().getColumn() ); + final String nodeContent = tokenProvider.apply( location ); + if ( !nodeContent.equals( node.getType() ) ) { + builder.append( " (" ); + builder.append( nodeContent ); + builder.append( ")" ); + } + } + builder.append( "\n" ); + for ( int i = 0; i < node.getChildCount(); i++ ) { + print( node.getChild( i ), builder, indentLevel + 1, tokenProvider ); + } + } +} diff --git a/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TurtleSyntaxTree.java b/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TurtleSyntaxTree.java new file mode 100644 index 000000000..66649ad48 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src/main/java/org/eclipse/esmf/treesitterturtle/TurtleSyntaxTree.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.treesitterturtle; + +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.treesitter.TSNode; +import org.treesitter.TSTree; + +/** + * Represents the concrete syntax tree of a turtle document + */ +public class TurtleSyntaxTree { + private final Supplier sourceRepresentationSupplier; + private final Node rootNode; + + public sealed interface Node { + String type(); + + Location location(); + + default boolean isError() { + return false; + } + + default List children() { + return List.of(); + } + } + + /** + * Represents one node in the concrete syntax tree in the source document + * + * @param type the type of token, expected to be one of the constants in {@link ParserTokenType} + * @param content the actual content of the token + * @param location the location in the source document + * @param children children of the node + */ + public record Token( + String type, + String content, + Location location, + List children + ) implements Node {} + + public record Error( + String type, + Location location, + boolean isMissing, + boolean isExtra + ) implements Node { + @Override + public boolean isError() { + return true; + } + } + + public record Location( + int fromLine, + int fromColumn, + int toLine, + int toColumn + ) {} + + /** + * Provides the token (substring) for a given location + */ + public interface TokenProvider extends Function { + } + + /** + * TokenProvider implementation for an input document given as a string + */ + public static class StringTokenProvider implements TokenProvider { + private final String sourceDocument; + + public StringTokenProvider( final String sourceDocument ) { + this.sourceDocument = sourceDocument; + } + + @Override + public String apply( final Location location ) { + if ( sourceDocument == null || sourceDocument.isEmpty() ) { + return ""; + } + + int startIndex = 0; + int currentLine = 0; + for ( int i = 0; i < sourceDocument.length() && currentLine < location.fromLine(); i++ ) { + if ( sourceDocument.charAt( i ) == '\n' ) { + currentLine++; + } + startIndex = i + 1; + } + startIndex += location.fromColumn(); + int endIndex = 0; + currentLine = 0; + + for ( int i = 0; i < sourceDocument.length() && currentLine < location.toLine(); i++ ) { + if ( sourceDocument.charAt( i ) == '\n' ) { + currentLine++; + } + endIndex = i + 1; + } + endIndex += location.toColumn(); + startIndex = Math.clamp( startIndex, 0, sourceDocument.length() ); + endIndex = Math.clamp( endIndex, 0, sourceDocument.length() ); + + if ( startIndex > endIndex ) { + return ""; + } + + return sourceDocument.substring( startIndex, endIndex ); + } + } + + private TurtleSyntaxTree( final Node rootNode, final Supplier sourceRepresentationSupplier ) { + this.rootNode = rootNode; + this.sourceRepresentationSupplier = sourceRepresentationSupplier; + } + + public Node rootNode() { + return rootNode; + } + + public Supplier sourceRepresentationSupplier() { + return sourceRepresentationSupplier; + } + + public static TurtleSyntaxTree fromConcreteSyntaxTree( final TSTree syntaxTree, final Supplier sourceRepresentationSupplier, + final TokenProvider tokenProvider ) { + return new TurtleSyntaxTree( nodeForTsNode( syntaxTree.getRootNode(), tokenProvider ), sourceRepresentationSupplier ); + } + + private static Node nodeForTsNode( final TSNode inputNode, final TokenProvider tokenProvider ) { + final Location location = new Location( + inputNode.getStartPoint().getRow(), + inputNode.getStartPoint().getColumn(), + inputNode.getEndPoint().getRow(), + inputNode.getEndPoint().getColumn() ); + if ( inputNode.isError() ) { + return new Error( inputNode.getType(), location, inputNode.isMissing(), inputNode.isExtra() ); + } + final String token = tokenProvider.apply( location ); + final List children = IntStream.range( 0, inputNode.getChildCount() ) + .mapToObj( inputNode::getChild ) + .filter( Objects::nonNull ) + .map( child -> nodeForTsNode( child, tokenProvider ) ) + .toList(); + return new Token( inputNode.getType(), token, location, children ); + } + + private Stream nodes( final Node fromNode ) { + final Stream children = fromNode.children().stream().flatMap( this::nodes ); + return Stream.concat( Stream.of( fromNode ), children ) + .sorted( Comparator.comparingInt( node -> node.location().fromLine() ) + .thenComparingInt( node -> node.location().fromColumn() ) ); + } + + public Stream nodes() { + return nodes( rootNode() ); + } + + public Stream tokens() { + return nodes().filter( Token.class::isInstance ).map( Token.class::cast ); + } +} diff --git a/core/esmf-tree-sitter-turtle/src/main/js/tree-sitter.json b/core/esmf-tree-sitter-turtle/src/main/js/tree-sitter.json new file mode 100644 index 000000000..4b6137bde --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src/main/js/tree-sitter.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://tree-sitter.github.io/tree-sitter/assets/schemas/config.schema.json", + "grammars": [ + { + "name": "turtle", + "camelcase": "Turtle", + "title": "RDF/Turtle Parser", + "scope": "source.turtle", + "file-types": [ + "ttl" + ], + "injection-regex": "^turtle$", + "class-name": "TreeSitterTurtle" + } + ], + "metadata": { + "version": "0.0.0", + "license": "EPL-2.0", + "description": "Turtle grammar for tree-sitter", + "authors": [ + { + "name": "Eclipse Semantic Modeling Framework Team", + "email": "esmf-dev@eclipse.org", + "url": "https://eclipse-esmf.github.io/" + } + ] + }, + "bindings": { + "c": false, + "go": false, + "node": false, + "python": false, + "rust": false, + "swift": false, + "zig": false + } +} diff --git a/core/esmf-tree-sitter-turtle/src/test/java/org/eclipse/esmf/treesitterturtle/TreeSitterTurtleTest.java b/core/esmf-tree-sitter-turtle/src/test/java/org/eclipse/esmf/treesitterturtle/TreeSitterTurtleTest.java new file mode 100644 index 000000000..64dd88861 --- /dev/null +++ b/core/esmf-tree-sitter-turtle/src/test/java/org/eclipse/esmf/treesitterturtle/TreeSitterTurtleTest.java @@ -0,0 +1,389 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.treesitterturtle; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.treesitter.TSLanguage; +import org.treesitter.TSNode; +import org.treesitter.TSParser; +import org.treesitter.TSTree; + +@SuppressWarnings( "HttpUrlsUsage" ) +public class TreeSitterTurtleTest { + private TSParser parser; + + @BeforeEach + void setUp() { + parser = new TSParser(); + final TSLanguage turtle = new TreeSitterTurtle(); + parser.setLanguage( turtle ); + } + + @Test + void testBasicPrefixAndTriple() { + final String content = """ + @prefix : . + :a a :b . + """; + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + assertThat( rootNode.hasError() ).isFalse(); + assertThat( rootNode.getChild( 0 ).getChild( 0 ).getChild( 0 ).getGrammarType() ).isEqualTo( "@prefix" ); + } + + @Test + void testNumericLiterals() { + final String content = """ + @prefix ex: . + + ex:entity ex:intValue 42 . + ex:entity ex:decimalValue 3.14 . + ex:entity ex:doubleValue 1.23e10 . + ex:entity ex:negativeInt -100 . + ex:entity ex:positiveDouble +2.5 . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + + // Verify the document parses successfully with numeric literals + final String treeString = TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ); + assertThat( treeString ).contains( "42" ); + assertThat( treeString ).contains( "3.14" ); + assertThat( treeString ).contains( "1.23e10" ); + } + + @Test + void testStringLiterals() { + final String content = """ + @prefix ex: . + + ex:entity ex:simpleString "Hello World" . + ex:entity ex:stringWithEscapes "Line 1\\nLine 2\\tTabbed" . + ex:entity ex:multilineString \"\"\"This is a + multiline + string literal\"\"\" . + ex:entity ex:singleQuote 'Single quoted string' . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + } + + @Test + void testBooleanLiterals() { + final String content = """ + @prefix ex: . + + ex:entity ex:isTrue true . + ex:entity ex:isFalse false . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "true" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "false" ); + } + + @Test + void testLanguageTaggedLiterals() { + final String content = """ + @prefix ex: . + @prefix rdfs: . + + ex:entity rdfs:label "Hello"@en . + ex:entity rdfs:label "Hallo"@de . + ex:entity rdfs:label "Bonjour"@fr . + ex:entity rdfs:label "こんにちは"@ja . + ex:entity rdfs:comment "Multi-word label"@en-US . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "@en" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "@de" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "@fr" ); + } + + @Test + void testTypedLiterals() { + final String content = """ + @prefix ex: . + @prefix xsd: . + + ex:entity ex:dateValue "2026-04-30"^^xsd:date . + ex:entity ex:intValue "42"^^xsd:integer . + ex:entity ex:boolValue "true"^^xsd:boolean . + ex:entity ex:customType "custom value"^^ex:MyType . + ex:entity ex:fullUri "value"^^ . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "^^" ); + } + + @Test + void testRdfListSyntax() { + final String content = """ + @prefix ex: . + @prefix rdf: . + + ex:entity ex:emptyList () . + ex:entity ex:numberList (1 2 3 4 5) . + ex:entity ex:stringList ("a" "b" "c") . + ex:entity ex:mixedList (1 "two" 3.0 true ex:resource) . + ex:entity ex:nestedList (1 (2 3) 4) . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "(" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( ")" ); + } + + @Test + void testAnonymousNodes() { + final String content = """ + @prefix ex: . + + ex:entity ex:hasBlankNode [ + ex:property1 "value1" ; + ex:property2 "value2" + ] . + + ex:entity ex:simpleBlank [ ex:prop "val" ] . + + ex:entity ex:nestedBlank [ + ex:inner [ + ex:nested "deeply" + ] + ] . + + # Blank node with multiple predicates and objects + ex:person + ex:firstName "John" ; + ex:lastName "Doe" ; + ex:age 30 ; + ex:knows [ + ex:firstName "Jane" ; + ex:lastName "Smith" + ] . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "[" ); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "]" ); + } + + @Test + void testComplexDocument() { + final String content = """ + @prefix ex: . + @prefix rdf: . + @prefix xsd: . + @base . + + ex:Person a ex:Class ; + ex:name "Person"@en ; + ex:properties ( + ex:firstName + ex:lastName + ex:age + ) . + + ex:john a ex:Person ; + ex:firstName "John" ; + ex:lastName "Doe" ; + ex:age "30"^^xsd:integer ; + ex:active true ; + ex:salary 50000.50 ; + ex:address [ + ex:street "123 Main St" ; + ex:city "Anytown" ; + ex:country "USA"@en + ] ; + ex:hobbies ("reading" "coding" "music") . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + } + + @Test + void testBrokenSyntaxMissingDot() { + final String content = """ + @prefix ex: + + ex:entity ex:property "value" . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + // Should have errors due to missing dot after prefix declaration + assertThat( rootNode.hasError() ).isTrue(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "ERROR" ); + } + + @Test + void testBrokenSyntaxInvalidUri() { + final String content = """ + @prefix ex: . + + ex:entity ex:property . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + // Should have errors due to invalid URI + assertThat( rootNode.hasError() ).isTrue(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "ERROR" ); + } + + @Test + void testBrokenSyntaxUnterminatedString() { + final String content = """ + @prefix ex: . + + ex:entity ex:property "unterminated string . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + // Should have errors due to unterminated string + assertThat( rootNode.hasError() ).isTrue(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "ERROR" ); + } + + @Test + void testBrokenSyntaxMismatchedBrackets() { + final String content = """ + @prefix ex: . + + ex:entity ex:property [ + ex:nested "value" + . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + // Should have errors due to mismatched brackets + assertThat( rootNode.hasError() ).isTrue(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "ERROR" ); + } + + @Test + void testBrokenSyntaxInvalidPrefix() { + final String content = """ + @prefix 123invalid: . + + 123invalid:entity ex:property "value" . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + // Should have errors due to invalid prefix name (starts with number) + assertThat( rootNode.hasError() ).isTrue(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "ERROR" ); + } + + @Test + void testBrokenSyntaxIncompleteTriple() { + final String content = """ + @prefix ex: . + + ex:entity ex:property . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + // Should have errors due to missing object + assertThat( rootNode.hasError() ).isTrue(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).contains( "ERROR" ); + } + + @Test + void testSemicolonAndCommaSyntax() { + final String content = """ + @prefix ex: . + + ex:entity + ex:prop1 "value1" ; + ex:prop2 "value2" , "value3" , "value4" ; + ex:prop3 "value5" . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + } + + @Test + void testComments() { + final String content = """ + # This is a comment + @prefix ex: . + + # Another comment + ex:entity ex:property "value" . # Inline comment + + # Multi-line comments + # are also supported + ex:entity2 ex:property2 "value2" . + """; + + final TSTree tree = parser.parseString( null, content ); + final TSNode rootNode = tree.getRootNode(); + + assertThat( rootNode.hasError() ).isFalse(); + assertThat( TreeSitterUtil.print( rootNode, new TurtleSyntaxTree.StringTokenProvider( content ) ) ).doesNotContain( "ERROR" ); + } +} diff --git a/core/esmf-turtle-language-server/pom.xml b/core/esmf-turtle-language-server/pom.xml new file mode 100644 index 000000000..2d91f8a30 --- /dev/null +++ b/core/esmf-turtle-language-server/pom.xml @@ -0,0 +1,82 @@ + + + + + + org.eclipse.esmf + esmf-sdk-parent + DEV-SNAPSHOT + ../../pom.xml + + 4.0.0 + + esmf-turtle-language-server + ESMF RDF/Turtle Language Server + jar + + + 1.0.0 + + + + + org.eclipse.esmf + esmf-aspect-meta-model-java + + + org.eclipse.esmf + esmf-aspect-model-validator + + + org.eclipse.esmf + esmf-tree-sitter-turtle + + + org.slf4j + slf4j-nop + + + + + org.eclipse.lsp4j + org.eclipse.lsp4j + ${lsp4j.version} + + + org.slf4j + slf4j-api + + + ch.qos.logback + logback-classic + + + + org.junit.jupiter + junit-jupiter + test + + + org.assertj + assertj-core + test + + + net.jqwik + jqwik + + + diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/TurtleLanguageServer.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/TurtleLanguageServer.java new file mode 100644 index 000000000..8c9ddf4f8 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/TurtleLanguageServer.java @@ -0,0 +1,182 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.nio.channels.AsynchronousServerSocketChannel; +import java.nio.channels.AsynchronousSocketChannel; +import java.nio.channels.Channels; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executors; +import java.util.function.Function; + +import org.eclipse.esmf.turtle.languageserver.aspect.request.ValidateDocumentParams; +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; +import org.eclipse.esmf.turtle.languageserver.lsp.text.TurtleTextDocumentService; +import org.eclipse.esmf.turtle.languageserver.lsp.workspace.TurtleWorkspaceService; +import org.eclipse.esmf.turtle.languageserver.structure.TurtleTokenService; + +import org.eclipse.lsp4j.InitializeParams; +import org.eclipse.lsp4j.InitializeResult; +import org.eclipse.lsp4j.SaveOptions; +import org.eclipse.lsp4j.SemanticTokensWithRegistrationOptions; +import org.eclipse.lsp4j.ServerCapabilities; +import org.eclipse.lsp4j.TextDocumentSyncKind; +import org.eclipse.lsp4j.TextDocumentSyncOptions; +import org.eclipse.lsp4j.jsonrpc.Launcher; +import org.eclipse.lsp4j.jsonrpc.services.JsonRequest; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.LanguageClientAware; +import org.eclipse.lsp4j.services.LanguageServer; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.eclipse.lsp4j.services.WorkspaceService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TurtleLanguageServer implements LanguageServer, LanguageClientAware { + // R=18, D=4, F=6 + public static final int DEFAULT_PORT = 1846; + + private static final Logger LOG = LoggerFactory.getLogger( TurtleLanguageServer.class ); + private static volatile boolean serverRunning = true; + private final TurtleTextDocumentService textDocumentService; + private final TurtleWorkspaceService workspaceService; + + public TurtleLanguageServer() { + textDocumentService = new TurtleTextDocumentService(); + workspaceService = new TurtleWorkspaceService( textDocumentService ); + } + + @Override + public CompletableFuture initialize( final InitializeParams params ) { + final ServerCapabilities capabilities = new ServerCapabilities(); + final TextDocumentSyncOptions syncOptions = new TextDocumentSyncOptions(); + syncOptions.setOpenClose( true ); + syncOptions.setChange( TextDocumentSyncKind.Full ); + syncOptions.setSave( new SaveOptions( true ) ); + capabilities.setTextDocumentSync( syncOptions ); + capabilities.setDefinitionProvider( true ); + capabilities.setSemanticTokensProvider( + new SemanticTokensWithRegistrationOptions( TurtleTokenService.SUPPORTED_TOKEN_TYPES, true, false ) ); + return CompletableFuture.completedFuture( new InitializeResult( capabilities ) ); + } + + @Override + public CompletableFuture shutdown() { + textDocumentService.shutdown(); + return CompletableFuture.completedFuture( null ); + } + + @Override + public void exit() { + System.exit( 0 ); + } + + @Override + public TextDocumentService getTextDocumentService() { + return textDocumentService; + } + + @Override + public WorkspaceService getWorkspaceService() { + return workspaceService; + } + + @Override + public void connect( final LanguageClient client ) { + textDocumentService.connect( client ); + } + + @JsonRequest( "turtle/aspectValidation/validateDocument" ) + public CompletableFuture validateDocument( final ValidateDocumentParams params ) { + if ( params == null || params.uri() == null ) { + return CompletableFuture.completedFuture( DiagnosticReport.EMPTY ); + } + return CompletableFuture.completedFuture( textDocumentService.validateDocument( params.uri() ) ); + } + + /** + * Starts the language server using stdin/stdout communication. + * This method does not return. + */ + @SuppressWarnings( "UseOfSystemOutOrSystemErr" ) + public static void launchForStdio() { + final TurtleLanguageServer server = new TurtleLanguageServer(); + try { + final Launcher launcher = Launcher.createLauncher( server, LanguageClient.class, System.in, System.out ); + server.connect( launcher.getRemoteProxy() ); + launcher.startListening().get(); + } catch ( final InterruptedException exception ) { + Thread.currentThread().interrupt(); + LOG.error( "Language server listener was interrupted", exception ); + } catch ( final Exception exception ) { + LOG.error( "Language server terminated with an error", exception ); + } + } + + /** + * Starts the language server using socket communication. + * This method does not return. + * + * @param port the port to listen on + */ + public static void launchForSocket( final int port ) { + try ( final AsynchronousServerSocketChannel serverSocket = AsynchronousServerSocketChannel.open() ) { + serverSocket.bind( new InetSocketAddress( "localhost", port ) ); + LOG.info( "Starting language server on port {}", port ); + + while ( serverRunning ) { + LOG.info( "Waiting for client connection on port {}", port ); + final AsynchronousSocketChannel socketChannel = serverSocket.accept().get(); + LOG.info( "Client connected" ); + + // Handle each connection in a separate thread so we can immediately accept new connections + final Thread clientThread = new Thread( () -> handleClientConnection( socketChannel ) ); + clientThread.setName( "LSP-Client-Handler-" + System.currentTimeMillis() ); + clientThread.setDaemon( true ); + clientThread.start(); + } + } catch ( final IOException exception ) { + LOG.error( "Could not launch language server", exception ); + } catch ( final InterruptedException exception ) { + Thread.currentThread().interrupt(); + serverRunning = false; + LOG.error( "Language server listener was interrupted", exception ); + } catch ( final ExecutionException exception ) { + LOG.error( "Error accepting client connection", exception ); + } + } + + private static void handleClientConnection( final AsynchronousSocketChannel socketChannel ) { + try ( final var inputStream = Channels.newInputStream( socketChannel ); + final var outputStream = Channels.newOutputStream( socketChannel ); + final var executorService = Executors.newCachedThreadPool(); + socketChannel ) { + final TurtleLanguageServer languageServer = new TurtleLanguageServer(); + final Launcher launcher = + Launcher.createIoLauncher( languageServer, LanguageClient.class, inputStream, + outputStream, executorService, Function.identity() ); + languageServer.connect( launcher.getRemoteProxy() ); + launcher.startListening().get(); + LOG.info( "Client disconnected" ); + } catch ( final InterruptedException exception ) { + Thread.currentThread().interrupt(); + LOG.error( "Client connection handler was interrupted", exception ); + } catch ( final Exception exception ) { + LOG.error( "Error handling client connection", exception ); + } + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/diagnostic/AspectDiagnosticMapper.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/diagnostic/AspectDiagnosticMapper.java new file mode 100644 index 000000000..f75c9e55e --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/diagnostic/AspectDiagnosticMapper.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.aspect.diagnostic; + +import java.util.List; + +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnostic; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDocumentDiagnostic; +import org.eclipse.esmf.turtle.languageserver.lsp.text.Document; + +import org.eclipse.lsp4j.Diagnostic; +import org.eclipse.lsp4j.DiagnosticSeverity; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; + +public final class AspectDiagnosticMapper { + public List toDiagnostics( final Document document, final DiagnosticReport result ) { + return result.diagnostics().stream() + .filter( violation -> appliesToDocument( document, violation ) ) + .map( this::toDiagnostic ) + .toList(); + } + + private boolean appliesToDocument( final Document document, final TurtleDiagnostic turtleDiagnostic ) { + if ( turtleDiagnostic instanceof final TurtleDocumentDiagnostic turtleDocumentDiagnostic ) { + return document.getUri().equals( turtleDocumentDiagnostic.sourceLocation() ); + } + return true; + } + + private Diagnostic toDiagnostic( final TurtleDiagnostic turtleDiagnostic ) { + final Diagnostic diagnostic = new Diagnostic(); + diagnostic.setSeverity( DiagnosticSeverity.Error ); + diagnostic.setMessage( turtleDiagnostic.message() ); + diagnostic.setCode( turtleDiagnostic.code().code() ); + if ( turtleDiagnostic instanceof final TurtleDocumentDiagnostic turtleDocumentDiagnostic ) { + diagnostic.setRange( toRange( turtleDocumentDiagnostic ) ); + } + return diagnostic; + } + + private Range toRange( final TurtleDocumentDiagnostic violation ) { + return new Range( new Position( violation.fromLine(), violation.fromColumn() ), + new Position( violation.toLine(), violation.toColumn() ) ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/request/ValidateDocumentParams.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/request/ValidateDocumentParams.java new file mode 100644 index 000000000..3317ffedf --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/request/ValidateDocumentParams.java @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.aspect.request; + +public record ValidateDocumentParams( + String uri, + String reason +) {} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/service/AspectModelValidationService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/service/AspectModelValidationService.java new file mode 100644 index 000000000..8bff725d6 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/service/AspectModelValidationService.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.aspect.service; + +import java.io.InputStream; +import java.net.URI; +import java.util.List; +import java.util.Optional; + +import org.apache.jena.rdf.model.RDFNode; +import org.apache.jena.riot.RiotException; + +import org.eclipse.esmf.aspectmodel.loader.AspectModelLoader; +import org.eclipse.esmf.aspectmodel.resolver.AspectModelFileLoader; +import org.eclipse.esmf.aspectmodel.resolver.exceptions.ParserException; +import org.eclipse.esmf.aspectmodel.resolver.modelfile.RawAspectModelFile; +import org.eclipse.esmf.aspectmodel.resolver.parser.TokenRegistry; +import org.eclipse.esmf.aspectmodel.shacl.violation.Violation; +import org.eclipse.esmf.aspectmodel.validation.InvalidSyntaxViolation; +import org.eclipse.esmf.aspectmodel.validation.services.AspectModelValidator; +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleBaseDiagnostic; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnostic; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnosticsService; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDocumentDiagnostic; +import org.eclipse.esmf.turtle.languageserver.lsp.text.Document; +import org.eclipse.esmf.turtle.languageserver.lsp.text.ParsedDocument; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AspectModelValidationService implements TurtleDiagnosticsService { + private static final Logger LOG = LoggerFactory.getLogger( AspectModelValidationService.class ); + + private final AspectModelLoader loader; + private final AspectModelValidator validator; + + public AspectModelValidationService() { + this( new AspectModelLoader(), new AspectModelValidator() ); + } + + AspectModelValidationService( final AspectModelLoader loader, final AspectModelValidator validator ) { + this.loader = loader; + this.validator = validator; + } + + @Override + public DiagnosticReport onChange( final ParsedDocument document ) { + return DiagnosticReport.EMPTY; + } + + @Override + public DiagnosticReport defaultValidate( final ParsedDocument parsedDocument ) { + final Document document = parsedDocument.sourceDocument(); + try ( final InputStream inputStream = document.getInputStream() ) { + LOG.debug( "[load] loading aspect model from {}", document.getUri() ); + final TurtleSyntaxTree syntaxTree = TurtleSyntaxTree.fromConcreteSyntaxTree( parsedDocument.concreteSyntaxTree(), + () -> parsedDocument.sourceDocument().getContent(), + location -> parsedDocument.sourceDocument().subSequence( location.fromLine(), location.fromColumn(), + location.toLine(), location.toColumn() ) ); + final RawAspectModelFile file = AspectModelFileLoader.load( syntaxTree, URI.create( document.getUri() ) ); + final List violations = + validator.validateModel( () -> loader.loadAspectModelFiles( List.of( file ) ) ); + LOG.debug( "[validate] validation finished for {} with {} violation(s)", document.getUri(), violations.size() ); + return new DiagnosticReport( violations.stream().flatMap( violation -> toViolationInfo( violation ).stream() ).toList() ); + } catch ( final RiotException exception ) { + // Ignore. Syntax errors are handled by the TurtleSyntaxDiagnosticsService + return DiagnosticReport.EMPTY; + } catch ( final ParserException exception ) { + // Can happen for cases where Jena complains but TreeSitter doesn't + return new DiagnosticReport( diagnosticFromParserException( exception, parsedDocument.getUri() ) ); + } catch ( final Exception exception ) { + LOG.error( "[validate] unexpected runtime failure for {}", document.getUri(), exception ); + return new DiagnosticReport( exception.getMessage(), TurtleDiagnostic.TurtleCode.E0000 ); + } + } + + private TurtleDiagnostic diagnosticFromParserException( final ParserException exception, final String sourceLocation ) { + return new TurtleDocumentDiagnostic( exception.getMessage(), TurtleDiagnostic.TurtleCode.E0003, sourceLocation, + (int) exception.getLine() - 1, (int) exception.getColumn() - 1, + (int) exception.getLine() - 1, (int) exception.getColumn() ); + } + + private TurtleDiagnostic.Code classifyViolation( final Violation violation ) { + // TODO + return TurtleDiagnostic.TurtleCode.E0000; + } + + private Optional toViolationInfo( final Violation violation ) { + return switch ( violation ) { + // Syntax violation diagnostics are provided by TurtleSyntaxDiagnosticsService + case final InvalidSyntaxViolation _ -> Optional.empty(); + // TODO Add other specific violations here + default -> { + final TurtleSyntaxTree.Location location = Optional.ofNullable( violation.highlight() ) + .map( RDFNode::asNode ) + .flatMap( TokenRegistry::getToken ) + .flatMap( smartToken -> Optional.ofNullable( smartToken.getTreesitterToken() ) ) + .map( TurtleSyntaxTree.Token::location ) + .orElse( null ); + yield location == null + ? Optional.of( new TurtleBaseDiagnostic( + violation.message(), + classifyViolation( violation ) ) ) + : Optional.of( new TurtleDocumentDiagnostic( + violation.message(), + classifyViolation( violation ), + violation.sourceLocation().map( URI::toString ).orElseThrow(), + location.fromLine(), + location.fromColumn(), + location.toLine(), + location.toColumn() ) ); + } + }; + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/service/AspectValidationCoordinator.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/service/AspectValidationCoordinator.java new file mode 100644 index 000000000..895610bd3 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/aspect/service/AspectValidationCoordinator.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.aspect.service; + +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.BiConsumer; + +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnostic; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnosticsService; +import org.eclipse.esmf.turtle.languageserver.lsp.text.Document; +import org.eclipse.esmf.turtle.languageserver.lsp.text.ParsedDocument; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AspectValidationCoordinator implements AutoCloseable { + private static final Logger LOG = LoggerFactory.getLogger( AspectValidationCoordinator.class ); + private final TurtleDiagnosticsService validationService; + private final ExecutorService executorService; + private final Map> inFlight = new ConcurrentHashMap<>(); + private final Map generations = new ConcurrentHashMap<>(); + + public AspectValidationCoordinator( final TurtleDiagnosticsService validationService ) { + this( validationService, Executors.newSingleThreadExecutor( Thread.ofPlatform().name( "aspect-validation-", 0 ).factory() ) ); + } + + AspectValidationCoordinator( final TurtleDiagnosticsService validationService, final ExecutorService executorService ) { + this.validationService = validationService; + this.executorService = executorService; + } + + public long nextGeneration( final Document document ) { + return generations.computeIfAbsent( document, ignored -> new AtomicLong() ).incrementAndGet(); + } + + public long currentGeneration( final Document document ) { + final AtomicLong generation = generations.get( document ); + return generation != null ? generation.get() : 0L; + } + + public void cancel( final Document document ) { + final CompletableFuture previous = inFlight.remove( document ); + if ( previous != null ) { + LOG.debug( "[cancel] cancelling previous aspect validation for {}", document.getUri() ); + previous.cancel( true ); + } + } + + public void submit( final ParsedDocument document, final long generation, final BiConsumer callback ) { + cancel( document.sourceDocument() ); + final CompletableFuture future = CompletableFuture.supplyAsync( + () -> validationService.defaultValidate( document ), + executorService + ); + inFlight.put( document.sourceDocument(), future ); + future.whenComplete( ( result, throwable ) -> { + inFlight.remove( document.sourceDocument(), future ); + if ( throwable instanceof CancellationException || future.isCancelled() ) { + LOG.debug( "[cancel] aspect validation cancelled for {}", document.getUri() ); + return; + } + if ( throwable != null ) { + LOG.error( "[publish diagnostics] aspect validation failed for {}", document.getUri(), throwable ); + callback.accept( generation, new DiagnosticReport( throwable.getMessage(), TurtleDiagnostic.TurtleCode.E0002 ) ); + return; + } + callback.accept( generation, result ); + } ); + } + + public DiagnosticReport validateSync( final ParsedDocument document ) { + return validationService.defaultValidate( document ); + } + + @Override + public void close() { + inFlight.values().forEach( future -> future.cancel( true ) ); + executorService.shutdownNow(); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/common/uri/DocumentUriResolver.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/common/uri/DocumentUriResolver.java new file mode 100644 index 000000000..84044ae0e --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/common/uri/DocumentUriResolver.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.common.uri; + +import java.net.URI; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class DocumentUriResolver { + private DocumentUriResolver() {} + + public static Path toPath( final String uri ) { + if ( uri == null || !uri.startsWith( "file:" ) ) { + return null; + } + + return Paths.get( URI.create( uri ) ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/DiagnosticReport.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/DiagnosticReport.java new file mode 100644 index 000000000..3633bc41d --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/DiagnosticReport.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.diagnostic; + +import java.util.List; + +import com.google.common.collect.Streams; + +public record DiagnosticReport( + List diagnostics +) { + public static final DiagnosticReport EMPTY = new DiagnosticReport( List.of() ); + + public DiagnosticReport( final TurtleDiagnostic diagnostic ) { + this( List.of( diagnostic ) ); + } + + /** + * Convenience constructor to create are report for one {@link TurtleBaseDiagnostic} + * + * @param message the message + * @param code the code + */ + public DiagnosticReport( final String message, final TurtleDiagnostic.TurtleCode code ) { + this( new TurtleBaseDiagnostic( message, code ) ); + } + + /** + * Create a new DiagnosticsReport from this and another + * + * @param diagnosticReport the other report + * @return the new merged report + */ + public DiagnosticReport merge( final DiagnosticReport diagnosticReport ) { + return new DiagnosticReport( Streams.concat( diagnostics.stream(), diagnosticReport.diagnostics().stream() ).toList() ); + } + + public boolean isEmpty() { + return diagnostics.isEmpty(); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleBaseDiagnostic.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleBaseDiagnostic.java new file mode 100644 index 000000000..e0ef06b81 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleBaseDiagnostic.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.diagnostic; + +public class TurtleBaseDiagnostic implements TurtleDiagnostic { + private final String message; + private final Code code; + private final Severity severity; + + public TurtleBaseDiagnostic( final String message, final Code code ) { + this( message, code, Severity.ERROR ); + } + + public TurtleBaseDiagnostic( final String message, final Code code, final Severity severity ) { + this.message = message; + this.code = code; + this.severity = severity; + } + + @Override + public String message() { + return message; + } + + @Override + public Code code() { + return code; + } + + @Override + public Severity severity() { + return severity; + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDiagnostic.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDiagnostic.java new file mode 100644 index 000000000..9994bc8eb --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDiagnostic.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.diagnostic; + +public interface TurtleDiagnostic { + Code code(); + + String message(); + + Severity severity(); + + default boolean hasLocation() { + return false; + } + + interface Code { + String code(); + + String description(); + } + + enum TurtleCode implements Code { + E0000( "No more info available" ), + E0001( "Could not load document" ), + E0002( "Document validation failed" ), + E0003( "Syntax error" ); + + private final String description; + + TurtleCode( final String description ) { + this.description = description; + } + + @Override + public String code() { + return name(); + } + + @Override + public String description() { + return description; + } + } + + enum Severity { + ERROR, + WARNING, + INFO, + HINT + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDiagnosticsService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDiagnosticsService.java new file mode 100644 index 000000000..b285c3cc5 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDiagnosticsService.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.diagnostic; + +import org.eclipse.esmf.turtle.languageserver.lsp.text.ParsedDocument; + +public interface TurtleDiagnosticsService { + default DiagnosticReport defaultValidate( final ParsedDocument document ) { + return DiagnosticReport.EMPTY; + } + + default DiagnosticReport onOpen( final ParsedDocument document ) { + return defaultValidate( document ); + } + + default DiagnosticReport onChange( final ParsedDocument document ) { + return defaultValidate( document ); + } + + default DiagnosticReport onSave( final ParsedDocument document ) { + return defaultValidate( document ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDocumentDiagnostic.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDocumentDiagnostic.java new file mode 100644 index 000000000..0f5d584e7 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/diagnostic/TurtleDocumentDiagnostic.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.diagnostic; + +public class TurtleDocumentDiagnostic extends TurtleBaseDiagnostic { + private final String sourceLocation; + private final int fromLine; + private final int fromColumn; + private final int toLine; + private final int toColumn; + + public TurtleDocumentDiagnostic( final String message, final Code code, final String sourceLocation, final int fromLine, + final int fromColumn, final int toLine, final int toColumn ) { + super( message, code ); + this.sourceLocation = sourceLocation; + this.fromLine = fromLine; + this.fromColumn = fromColumn; + this.toLine = toLine; + this.toColumn = toColumn; + } + + public String sourceLocation() { + return sourceLocation; + } + + public int fromLine() { + return fromLine; + } + + public int fromColumn() { + return fromColumn; + } + + public int toLine() { + return toLine; + } + + public int toColumn() { + return toColumn; + } + + @Override + public boolean hasLocation() { + return true; + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/AspectDiagnosticsWorkflow.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/AspectDiagnosticsWorkflow.java new file mode 100644 index 000000000..5fe84fdb2 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/AspectDiagnosticsWorkflow.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.eclipse.esmf.turtle.languageserver.aspect.service.AspectValidationCoordinator; +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class AspectDiagnosticsWorkflow { + private static final Logger LOG = LoggerFactory.getLogger( AspectDiagnosticsWorkflow.class ); + private final AspectValidationCoordinator aspectValidationCoordinator; + private final TextDocumentClientNotifier clientNotifier; + private final Map diagnostics = new ConcurrentHashMap<>(); + + public AspectDiagnosticsWorkflow( + final AspectValidationCoordinator aspectValidationCoordinator, + final TextDocumentClientNotifier clientNotifier ) { + this.aspectValidationCoordinator = aspectValidationCoordinator; + this.clientNotifier = clientNotifier; + } + + public void onDocumentChanged( final Document document ) { + aspectValidationCoordinator.cancel( document ); + diagnostics.remove( document ); + } + + public void onDocumentClosed( final Document document ) { + aspectValidationCoordinator.cancel( document ); + diagnostics.clear(); + } + + public void onDocumentSaved( final ParsedDocument document ) { + final long generation = aspectValidationCoordinator.nextGeneration( document.sourceDocument() ); + aspectValidationCoordinator.submit( document, generation, ( completedGeneration, result ) -> { + final long currentGeneration = aspectValidationCoordinator.currentGeneration( document.sourceDocument() ); + if ( completedGeneration != currentGeneration ) { + LOG.debug( "[publish diagnostics] ignoring stale aspect diagnostics for uri={}, generation={}, current={}", document.getUri(), + completedGeneration, currentGeneration ); + return; + } + diagnostics.put( document.sourceDocument(), result ); + clientNotifier.publishDiagnostics( document.sourceDocument(), result ); + } ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/Document.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/Document.java new file mode 100644 index 000000000..ab74c2fa0 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/Document.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import java.io.InputStream; + +import org.eclipse.esmf.treesitterturtle.TurtleSyntaxTree; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.jspecify.annotations.Nullable; + +public class Document implements TurtleSyntaxTree.TokenProvider { + private final String uri; + private Rope content; + + public Document( final String uri, final String initialContent ) { + this.uri = uri; + content = new Rope( initialContent ); + } + + public String getUri() { + return uri; + } + + public String getContent() { + return content.toString(); + } + + public Rope getRope() { + return content; + } + + public InputStream getInputStream() { + return content.inputStream(); + } + + public int getIndex( final int targetLine, final int targetColumn ) { + return content.getIndex( targetLine, targetColumn ); + } + + public String subSequence( final int fromLine, final int fromColumn, final int toLine, final int toColumn ) { + final int fromIndex = getIndex( fromLine, fromColumn ); + final int toIndex = getIndex( toLine, toColumn ); + return content.subSequence( fromIndex, toIndex ).toString(); + } + + public void update( final @Nullable Range range, final String newContent ) { + if ( range == null ) { + content = new Rope( newContent ); + return; + } + final Position start = range.getStart(); + final Position end = range.getEnd(); + content = content.update( start.getLine(), start.getCharacter(), + end.getLine(), end.getCharacter(), newContent ); + } + + @Override + public String apply( final TurtleSyntaxTree.Location location ) { + return subSequence( location.fromLine(), location.fromColumn(), location.toLine(), location.toColumn() ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/DocumentAspectValidationService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/DocumentAspectValidationService.java new file mode 100644 index 000000000..e35fba4c3 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/DocumentAspectValidationService.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import org.eclipse.esmf.turtle.languageserver.aspect.service.AspectValidationCoordinator; +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleBaseDiagnostic; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnostic; + +public class DocumentAspectValidationService { + private final AspectValidationCoordinator aspectValidationCoordinator; + + public DocumentAspectValidationService( final AspectValidationCoordinator aspectValidationCoordinator ) { + this.aspectValidationCoordinator = aspectValidationCoordinator; + } + + public DiagnosticReport validateDocument( final String uri, final ParsedDocument document ) { + if ( document == null ) { + return new DiagnosticReport( + new TurtleBaseDiagnostic( "Document is not available in memory: " + uri, TurtleDiagnostic.TurtleCode.E0001 ) ); + } + return aspectValidationCoordinator.validateSync( document ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/ParsedDocument.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/ParsedDocument.java new file mode 100644 index 000000000..2f94291d9 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/ParsedDocument.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import org.treesitter.TSTree; + +public record ParsedDocument( + Document sourceDocument, + TSTree concreteSyntaxTree +) { + public String getUri() { + return sourceDocument().getUri(); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/Rope.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/Rope.java new file mode 100644 index 000000000..af05742a7 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/Rope.java @@ -0,0 +1,436 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.ArrayDeque; +import java.util.Arrays; +import java.util.Deque; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class Rope implements CharSequence { + private static final Logger LOG = LoggerFactory.getLogger( Rope.class ); + public static final Rope EMPTY = new Rope( "" ); + + String value; + Rope left; + Rope right; + int weight; + int linebreaks; + + /** + * Constructs a new rope from a given string value + * + * @param value the value + */ + public Rope( final String value ) { + this.value = value; + weight = value.length(); + linebreaks = linebreaks(); + } + + /** + * Constructs a new rope from left and right children + * + * @param left left children + * @param right right children + */ + private Rope( final Rope left, final Rope right ) { + this.left = left; + this.right = right; + weight = length( left ); + linebreaks = linebreaks(); + } + + public int linebreaks() { + return linebreaks( this ); + } + + private int linebreaks( final Rope rope ) { + if ( rope.left == null ) { + return (int) rope.value.chars().filter( ch -> ch == '\n' ).count(); + } + return linebreaks( rope.left ) + linebreaks( rope.right ); + } + + public int weight() { + return weight; + } + + /** + * Returns the length of the rope in characters + * + * @return the length + */ + @Override + public int length() { + return length( this ); + } + + private int length( final Rope r ) { + int len = 0; + for ( Rope rope = r; rope != null; rope = rope.right ) { + len += rope.weight; + } + return len; + } + + /** + * Concatenates another rope to this one + * + * @param rope the other rope + * @return the new rope representing the concatenated result + */ + public Rope concat( final Rope rope ) { + return rope == null ? this : concat( this, rope ); + } + + private @Nullable Rope concat( final @Nullable Rope rope1, final @Nullable Rope rope2 ) { + if ( rope1 == null && rope2 == null ) { + return EMPTY; + } else if ( rope1 == null ) { + return rope2; + } else if ( rope2 == null ) { + return rope1; + } + return new Rope( rope1, rope2 ); + } + + private char charAt( final Rope node, final int index ) { + if ( node.left == null ) { + return node.value.charAt( index ); + } + return node.weight > index ? charAt( node.left, index ) : charAt( node.right, index - node.weight ); + } + + /** + * Returns the character at given index + * + * @param index the index of the {@code char} value to be returned + * + * @return the character at the index + */ + @Override + public char charAt( final int index ) { + return charAt( this, index ); + } + + /** + * Splits this rope into two parts at the given index + * + * @param index the index + * @return an array with two elements, representing the left and right parts of the index. Both + * elements could be null, + * depending on the current rope value and the index + */ + public Rope[] split( final int index ) { + return split( this, index ); + } + + private Rope[] split( final Rope node, final int index ) { + final Rope node0; + final Rope node1; + if ( node.left == null ) { + if ( index == 0 ) { + node0 = null; + node1 = node; + } else if ( index == node.weight ) { + node0 = node; + node1 = null; + } else { + node0 = new Rope( node.value.substring( 0, index ) ); + node1 = new Rope( node.value.substring( index, node.weight ) ); + } + } else if ( index == node.weight ) { + node0 = node.left; + node1 = node.right; + } else if ( index < node.weight ) { + final Rope[] parts = split( node.left, index ); + node0 = parts[0]; + node1 = concat( parts[1], node.right ); + } else { + final Rope[] parts = split( node.right, index - node.weight ); + node0 = concat( node.left, parts[0] ); + node1 = parts[1]; + } + return new Rope[] { node0, node1 }; + } + + /** + * Returns the subsequence of characters between to indices + * + * @param start the start index, inclusive + * @param end the end index, exclusive + * @return the subsequence of characters + */ + @Override + public @NonNull Rope subSequence( final int start, final int end ) { + return split( start )[1].split( end - start )[0]; + } + + /** + * Inserts another rope at the given index. The index must be inside the rope. + * + * @param rope the other rope + * @param index the index. This must be less or equal than this rope's length + * @return the resulting new rope + */ + public Rope insert( final Rope rope, final int index ) { + final Rope[] parts = split( index ); + return concat( concat( parts[0], rope ), parts[1] ); + } + + /** + * Deletes a section at the given index. The index and index+length must be inside the rope. + * + * @param index the index + * @param length the length to delete + * @return the resulting new rope + */ + public Rope delete( final int index, final int length ) { + final Rope[] parts = split( index ); + final Rope result; + if ( parts[1] == null ) { + result = concat( parts[0], null ); + } else { + result = concat( parts[0], parts[1].split( length )[1] ); + } + return result == null ? EMPTY : result; + } + + /** + * Returns the index of the nth linebreak in a string, or -1 if no nth linebreak exists + * + * @param string the text to search in + * @param n the number of the linebreak + * @return the index or -1 + */ + private static int indexOfNthLinebreak( final String string, final int n ) { + int counter = n; + int pos = string.indexOf( '\n' ); + while ( --counter > 0 && pos != -1 ) { + pos = string.indexOf( '\n', pos + 1 ); + } + return pos; + } + + public Rope update( final int startLine, final int startColumn, final int endLine, final int endColumn, final String newContent ) { + final int startIndex = getIndex( startLine, startColumn ); + final int endIndex = getIndex( endLine, endColumn ); + // final int offset = endIndex == length() ? 0 : 1; + final int offset = 1; + final Rope resultAfterDeletion = startIndex == endIndex + ? this + : delete( startIndex, endIndex - startIndex + offset ); + return newContent.isEmpty() ? resultAfterDeletion : resultAfterDeletion.insert( new Rope( newContent ), startIndex ); + } + + public int getIndex( final int targetLine, final int targetColumn ) { + if ( targetLine == 0 ) { + return targetColumn; + } + if ( targetLine < 0 || targetColumn < 0 ) { + return -1; + } + + int currentIndex = 0; + int currentLine = 0; + final int length = length(); + + // Traverse the rope character by character until we reach the target line + while ( currentIndex < length && currentLine < targetLine ) { + if ( charAt( currentIndex ) == '\n' ) { + currentLine++; + } + currentIndex++; + } + + // Add the column offset + return currentIndex + targetColumn; + } + + /** + * Prints the rope as a tree structure + * + * @return the tree structure as a visual string + */ + public String print() { + return print( this, 0 ); + } + + private String print( final Rope rope, final int indentation ) { + if ( rope == null ) { + return "[]"; + } + final String indentString = new String( new char[indentation] ).replace( "\0", " " ); + return "[%s]\n%s-L:%s\n%s-R:%s".formatted( rope.value, + indentString, print( rope.left, indentation + 2 ), + indentString, print( rope.right, indentation + 2 ) ); + } + + public Rope rebalance() { + final Rope[] leaves = leaves(); + return merge( leaves, 0, leaves.length ); + } + + private Rope[] leaves() { + if ( left == null && right == null ) { + return new Rope[] { this }; + } + final Rope[] leftLeaves = left == null ? new Rope[0] : left.leaves(); + final Rope[] rightLeaves = right == null ? new Rope[0] : right.leaves(); + final Rope[] result = Arrays.copyOf( leftLeaves, leftLeaves.length + rightLeaves.length ); + System.arraycopy( rightLeaves, 0, result, leftLeaves.length, rightLeaves.length ); + return result; + } + + private Rope merge( final Rope[] leaves, final int start, final int end ) { + final int range = end - start; + if ( range == 1 ) { + return leaves[start]; + } + if ( range == 2 ) { + return new Rope( leaves[start], leaves[start + 1] ); + } + final int mid = start + ( range / 2 ); + return new Rope( merge( leaves, start, mid ), merge( leaves, mid, end ) ); + } + + @Override + public @NonNull String toString() { + try ( final InputStream inputStream = inputStream( StandardCharsets.UTF_8 ) ) { + return new String( inputStream.readAllBytes(), StandardCharsets.UTF_8 ); + } catch ( final IOException exception ) { + throw new RuntimeException( exception ); + } + } + + /** + * Equality of two ropes is given when the strings they encode are equal, regardless how their + * internal tree structure looks like + * + * @param object the other object + * @return true when the other object is a rope with the same encoded string + */ + @Override + public boolean equals( final Object object ) { + if ( this == object ) { + return true; + } + if ( object == null || getClass() != object.getClass() ) { + return false; + } + final Rope rope = (Rope) object; + return rope.toString().equals( toString() ); + } + + @Override + public int hashCode() { + return toString().hashCode(); + } + + public InputStream inputStream( final Charset encoding ) { + return new RopeInputStream( encoding ); + } + + public InputStream inputStream() { + return inputStream( StandardCharsets.UTF_8 ); + } + + /** + * Reads at most many bytes from the given offset into the buffer array, as the array provides, or + * fewer, if + * not as many are left at the offset. Returns 0 if the end of the source code was reached, + * otherwise the number of bytes read. + * + * @param buffer the buffer to write to + * @param offset offset to read from + * @return the number of bytes read + */ + public int read( final byte[] buffer, final int offset ) { + if ( buffer == null || offset < 0 || offset >= buffer.length ) { + return 0; + } + + try ( final InputStream stream = inputStream( StandardCharsets.UTF_8 ) ) { + final int bytesRead = stream.read( buffer, offset, buffer.length ); + return bytesRead == -1 ? 0 : bytesRead; + } catch ( final Exception exception ) { + LOG.debug( "Exception while reading document content", exception ); + return 0; + } + } + + public class RopeInputStream extends InputStream { + private final Deque stack; + private byte @Nullable [] currentBytes; + private int currentPosition; + private final Charset encoding; + + public RopeInputStream( final Charset encoding ) { + this.encoding = encoding; + stack = new ArrayDeque<>(); + currentBytes = null; + currentPosition = 0; + pushLeftmostPath( Rope.this ); + } + + private void pushLeftmostPath( final Rope node ) { + Rope current = node; + while ( current != null ) { + if ( current.left == null ) { + currentBytes = current.value.getBytes( encoding ); + currentPosition = 0; + break; + } else { + stack.push( current ); + current = current.left; + } + } + } + + private boolean moveToNextLeaf() { + while ( !stack.isEmpty() ) { + final Rope parent = stack.pop(); + if ( parent.right != null ) { + pushLeftmostPath( parent.right ); + return true; + } + } + currentBytes = null; + return false; + } + + @Override + public int read() { + while ( currentBytes == null || currentPosition >= currentBytes.length ) { + if ( !moveToNextLeaf() ) { + return -1; + } + } + + // Return byte as unsigned int (0-255) + final int result = currentBytes[currentPosition] & 0xFF; + currentPosition++; + return result; + } + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TextDocumentClientNotifier.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TextDocumentClientNotifier.java new file mode 100644 index 000000000..62d539927 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TextDocumentClientNotifier.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import java.util.List; + +import org.eclipse.esmf.turtle.languageserver.aspect.diagnostic.AspectDiagnosticMapper; +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; + +import org.eclipse.lsp4j.PublishDiagnosticsParams; +import org.eclipse.lsp4j.services.LanguageClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TextDocumentClientNotifier { + private static final Logger LOG = LoggerFactory.getLogger( TextDocumentClientNotifier.class ); + + private final AspectDiagnosticMapper diagnosticMapper; + private LanguageClient client; + + public TextDocumentClientNotifier( final AspectDiagnosticMapper diagnosticMapper ) { + this.diagnosticMapper = diagnosticMapper; + } + + public void connect( final LanguageClient client ) { + this.client = client; + } + + public void publishDiagnostics( final Document document, final DiagnosticReport diagnostics ) { + if ( client == null ) { + LOG.warn( "[publishDiagnostics] client is null, skipping for uri={}", document.getUri() ); + return; + } + + LOG.debug( "[publish diagnostics] publishing {} diagnostic(s) for uri={}", diagnostics.diagnostics().size(), document.getUri() ); + client.publishDiagnostics( + new PublishDiagnosticsParams( document.getUri(), diagnosticMapper.toDiagnostics( document, diagnostics ) ) ); + } + + public void publishEmptyDiagnostics( final String uri ) { + if ( client == null ) { + return; + } + + client.publishDiagnostics( new PublishDiagnosticsParams( uri, List.of() ) ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TreeSitterTurtleParserService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TreeSitterTurtleParserService.java new file mode 100644 index 000000000..d0f5ea9fe --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TreeSitterTurtleParserService.java @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.WeakHashMap; +import java.util.function.Function; + +import org.eclipse.esmf.treesitterturtle.TreeSitterTurtle; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.jspecify.annotations.Nullable; +import org.treesitter.TSInputEdit; +import org.treesitter.TSLanguage; +import org.treesitter.TSParser; +import org.treesitter.TSPoint; +import org.treesitter.TSTree; + +/** + * Service for parsing Turtle documents using Tree-sitter and maintaining their syntax trees. + * Supports incremental parsing for efficient updates when documents change. + */ +public class TreeSitterTurtleParserService implements Function { + private final TSParser parser; + private final Map syntaxTrees = new HashMap<>(); + private final Map previousDocumentStates = new WeakHashMap<>(); + + public TreeSitterTurtleParserService() { + parser = new TSParser(); + final TSLanguage turtle = new TreeSitterTurtle(); + parser.setLanguage( turtle ); + } + + @Override + public ParsedDocument apply( final Document document ) { + return new ParsedDocument( document, syntaxTrees.computeIfAbsent( document, this::parseDocument ) ); + } + + private TSTree parseDocument( final Document document ) { + previousDocumentStates.put( document, document.getRope() ); + return parser.parseString( null, document.getContent() ); + } + + /** + * Converts an LSP text change event into a Tree-sitter input edit. + * + * @param oldRope the rope before the change was applied + * @param changeEvent the LSP change event + * @return a TSInputEdit, or null if this is a full document change + */ + private @Nullable TSInputEdit treeChangeFromLspChange( final Rope oldRope, final TextDocumentContentChangeEvent changeEvent ) { + final Range range = changeEvent.getRange(); + if ( range == null ) { + return null; + } + + final Position startPos = range.getStart(); + final Position endPos = range.getEnd(); + final String newText = changeEvent.getText() != null ? changeEvent.getText() : ""; + + final TSPoint startPoint = new TSPoint( startPos.getLine(), startPos.getCharacter() ); + final TSPoint oldEndPoint = new TSPoint( endPos.getLine(), endPos.getCharacter() ); + final int startByte = oldRope.getIndex( startPos.getLine(), startPos.getCharacter() ); + final int oldEndByte = oldRope.getIndex( endPos.getLine(), endPos.getCharacter() ); + final TSPoint newEndPoint = calculateNewEndPoint( startPoint, newText ); + final int newEndByte = startByte + newText.getBytes( StandardCharsets.UTF_8 ).length; + return new TSInputEdit( startByte, oldEndByte, newEndByte, startPoint, oldEndPoint, newEndPoint ); + } + + /** + * Calculates the new end point after inserting text at the start point. + * + * @param startPoint the starting position + * @param newText the text being inserted + * @return the new end position after insertion + */ + private TSPoint calculateNewEndPoint( final TSPoint startPoint, final String newText ) { + if ( newText.isEmpty() ) { + return startPoint; + } + + // Count newlines in the inserted text + final long newlineCount = newText.chars().filter( ch -> ch == '\n' ).count(); + + if ( newlineCount == 0 ) { + // Single line insertion - same row, column advances by text length + final int newColumn = startPoint.getColumn() + newText.length(); + return new TSPoint( startPoint.getRow(), newColumn ); + } else { + // Multi-line insertion + final int lastNewlineIndex = newText.lastIndexOf( '\n' ); + final int newRow = startPoint.getRow() + (int) newlineCount; + final int newColumn = newText.length() - lastNewlineIndex - 1; + return new TSPoint( newRow, newColumn ); + } + } + + public void onOpen( final Document document ) { + syntaxTrees.put( document, parseDocument( document ) ); + } + + public void onChange( final Document document, final TextDocumentContentChangeEvent changeEvent ) { + final TSTree oldTree = syntaxTrees.get( document ); + if ( oldTree == null ) { + syntaxTrees.put( document, parseDocument( document ) ); + return; + } + + final Rope oldRope = previousDocumentStates.get( document ); + if ( oldRope == null ) { + syntaxTrees.put( document, parseDocument( document ) ); + return; + } + + final TSInputEdit edit = treeChangeFromLspChange( oldRope, changeEvent ); + if ( edit == null ) { + syntaxTrees.put( document, parseDocument( document ) ); + return; + } + + oldTree.edit( edit ); + final TSTree newTree = parser.parseString( oldTree, document.getContent() ); + syntaxTrees.put( document, newTree ); + previousDocumentStates.put( document, document.getRope() ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TurtleTextDocumentService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TurtleTextDocumentService.java new file mode 100644 index 000000000..d746fb2c4 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TurtleTextDocumentService.java @@ -0,0 +1,168 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import org.eclipse.esmf.turtle.languageserver.aspect.diagnostic.AspectDiagnosticMapper; +import org.eclipse.esmf.turtle.languageserver.aspect.service.AspectModelValidationService; +import org.eclipse.esmf.turtle.languageserver.aspect.service.AspectValidationCoordinator; +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnosticsService; +import org.eclipse.esmf.turtle.languageserver.structure.TurtleTokenService; +import org.eclipse.esmf.turtle.languageserver.turtle.TurtleSyntaxDiagnosticsService; +import org.eclipse.esmf.turtle.languageserver.turtle.navigation.TurtlePrefixDefinitionService; + +import org.eclipse.lsp4j.DefinitionParams; +import org.eclipse.lsp4j.DidChangeTextDocumentParams; +import org.eclipse.lsp4j.DidCloseTextDocumentParams; +import org.eclipse.lsp4j.DidOpenTextDocumentParams; +import org.eclipse.lsp4j.DidSaveTextDocumentParams; +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.LocationLink; +import org.eclipse.lsp4j.SemanticTokens; +import org.eclipse.lsp4j.SemanticTokensParams; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.eclipse.lsp4j.jsonrpc.messages.Either; +import org.eclipse.lsp4j.services.LanguageClient; +import org.eclipse.lsp4j.services.TextDocumentService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TurtleTextDocumentService implements TextDocumentService { + private static final Logger LOG = LoggerFactory.getLogger( TurtleTextDocumentService.class ); + + private final TextDocumentClientNotifier clientNotifier; + private final TurtlePrefixDefinitionService prefixDefinitionService; + private final AspectValidationCoordinator aspectValidationCoordinator; + private final TreeSitterTurtleParserService turtleParserService; + private final AspectDiagnosticsWorkflow aspectDiagnosticsWorkflow; + private final TurtleTokenService tokenService; + private final TurtleDiagnosticsService syntaxDiagnostics; + private final TurtleDiagnosticsService aspectModelValidation; + private final Map documents = new HashMap<>(); + + public TurtleTextDocumentService() { + clientNotifier = new TextDocumentClientNotifier( new AspectDiagnosticMapper() ); + prefixDefinitionService = new TurtlePrefixDefinitionService(); + aspectValidationCoordinator = new AspectValidationCoordinator( new AspectModelValidationService() ); + turtleParserService = new TreeSitterTurtleParserService(); + aspectDiagnosticsWorkflow = new AspectDiagnosticsWorkflow( aspectValidationCoordinator, clientNotifier ); + tokenService = new TurtleTokenService( turtleParserService ); + syntaxDiagnostics = new TurtleSyntaxDiagnosticsService(); + aspectModelValidation = new AspectModelValidationService(); + } + + public void connect( final LanguageClient client ) { + clientNotifier.connect( client ); + } + + public void shutdown() { + aspectValidationCoordinator.close(); + } + + public DiagnosticReport validateDocument( final String uri ) { + final Document document = documents.get( uri ); + if ( document == null ) { + return DiagnosticReport.EMPTY; + } + final ParsedDocument parsedDocument = turtleParserService.apply( document ); + return syntaxDiagnostics.defaultValidate( parsedDocument ) + .merge( aspectModelValidation.defaultValidate( parsedDocument ) ); + } + + @Override + public void didOpen( final DidOpenTextDocumentParams params ) { + final String uri = params.getTextDocument().getUri(); + final String content = params.getTextDocument().getText(); + LOG.info( "[didOpen] uri={}, contentLength={}", uri, content.length() ); + final Document document = new Document( uri, content ); + documents.put( uri, document ); + turtleParserService.onOpen( document ); + final ParsedDocument parsedDocument = turtleParserService.apply( document ); + final DiagnosticReport report = syntaxDiagnostics.onOpen( parsedDocument ) + .merge( aspectModelValidation.onOpen( parsedDocument ) ); + clientNotifier.publishDiagnostics( document, report ); + } + + @Override + public void didChange( final DidChangeTextDocumentParams params ) { + final String uri = params.getTextDocument().getUri(); + final Document document = documents.get( params.getTextDocument().getUri() ); + for ( final TextDocumentContentChangeEvent change : params.getContentChanges() ) { + document.update( change.getRange(), change.getText() ); + turtleParserService.onChange( document, change ); + } + LOG.debug( "[didChange] uri={}, changes={}", uri, params.getContentChanges().size() ); + final ParsedDocument parsedDocument = turtleParserService.apply( document ); + final DiagnosticReport syntaxReport = syntaxDiagnostics.onChange( parsedDocument ); + final DiagnosticReport report = syntaxReport.isEmpty() + ? syntaxReport + : syntaxReport.merge( aspectModelValidation.onChange( parsedDocument ) ); + clientNotifier.publishDiagnostics( document, report ); + aspectDiagnosticsWorkflow.onDocumentChanged( document ); + } + + @Override + public void didClose( final DidCloseTextDocumentParams params ) { + final String uri = params.getTextDocument().getUri(); + LOG.info( "[didClose] uri={}", uri ); + final Document document = documents.get( uri ); + aspectDiagnosticsWorkflow.onDocumentClosed( document ); + documents.remove( uri ); + clientNotifier.publishEmptyDiagnostics( uri ); + } + + @Override + public void didSave( final DidSaveTextDocumentParams params ) { + final String uri = params.getTextDocument().getUri(); + final Document document = documents.get( uri ); + LOG.info( "[didSave] uri={}", uri ); + document.getRope().rebalance(); + turtleParserService.onOpen( document ); + final ParsedDocument parsedDocument = turtleParserService.apply( document ); + final DiagnosticReport report = syntaxDiagnostics.onSave( parsedDocument ) + .merge( aspectModelValidation.onSave( parsedDocument ) ); + clientNotifier.publishDiagnostics( document, report ); + aspectDiagnosticsWorkflow.onDocumentSaved( parsedDocument ); + } + + @Override + public CompletableFuture semanticTokensFull( final SemanticTokensParams params ) { + final String uri = params.getTextDocument().getUri(); + final Document document = documents.get( uri ); + LOG.info( "[semanticTokensFull] uri={}", uri ); + final SemanticTokens semanticTokens = tokenService.buildSemanticTokens( document ); + return CompletableFuture.completedFuture( semanticTokens ); + } + + @Override + public CompletableFuture, List>> definition( final DefinitionParams params ) { + final String uri = params.getTextDocument().getUri(); + final Document document = documents.get( uri ); + if ( document == null ) { + return CompletableFuture.completedFuture( Either.forLeft( List.of() ) ); + } + + final Location declaration = prefixDefinitionService.findPrefixDeclaration( document, params.getPosition() ); + if ( declaration == null ) { + return CompletableFuture.completedFuture( Either.forLeft( List.of() ) ); + } + + return CompletableFuture.completedFuture( Either.forLeft( List.of( declaration ) ) ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/workspace/TurtleWorkspaceService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/workspace/TurtleWorkspaceService.java new file mode 100644 index 000000000..b33b751d4 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/lsp/workspace/TurtleWorkspaceService.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.workspace; + +import org.eclipse.lsp4j.DidChangeConfigurationParams; +import org.eclipse.lsp4j.DidChangeWatchedFilesParams; +import org.eclipse.lsp4j.services.WorkspaceService; + +import org.eclipse.esmf.turtle.languageserver.lsp.text.TurtleTextDocumentService; + +public class TurtleWorkspaceService implements WorkspaceService { + public TurtleWorkspaceService( final TurtleTextDocumentService textDocumentService ) {} + + @Override + public void didChangeConfiguration( final DidChangeConfigurationParams params ) {} + + @Override + public void didChangeWatchedFiles( final DidChangeWatchedFilesParams params ) {} +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/structure/TurtleTokenService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/structure/TurtleTokenService.java new file mode 100644 index 000000000..b2fcf7bf6 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/structure/TurtleTokenService.java @@ -0,0 +1,212 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.structure; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.IntStream; + +import org.eclipse.esmf.metamodel.vocabulary.SammNs; +import org.eclipse.esmf.treesitterturtle.ParserTokenType; +import org.eclipse.esmf.turtle.languageserver.lsp.text.Document; +import org.eclipse.esmf.turtle.languageserver.lsp.text.TreeSitterTurtleParserService; + +import org.eclipse.lsp4j.SemanticTokenModifiers; +import org.eclipse.lsp4j.SemanticTokenTypes; +import org.eclipse.lsp4j.SemanticTokens; +import org.eclipse.lsp4j.SemanticTokensLegend; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.treesitter.TSNode; +import org.treesitter.TSTree; + +import com.google.common.collect.ImmutableMap; + +/** + * Service that maps parser tokens to LSP semantic tokens + */ +public class TurtleTokenService { + private static final Logger LOG = LoggerFactory.getLogger( TurtleTokenService.class ); + public static final SemanticTokensLegend SUPPORTED_TOKEN_TYPES = new SemanticTokensLegend( + List.of( + SemanticTokenTypes.Type, + SemanticTokenTypes.Comment, + SemanticTokenTypes.Keyword, + SemanticTokenTypes.String, + SemanticTokenTypes.Class, + SemanticTokenTypes.Number, + SemanticTokenTypes.Decorator, + SemanticTokenTypes.Function, + SemanticTokenTypes.Property + ), + List.of( + SemanticTokenModifiers.DefaultLibrary, + SemanticTokenModifiers.Deprecated + ) + ); + + private static final Map PARSER_TOKEN_TO_SEMANTIC_TOKEN = ImmutableMap.builder() + .put( ParserTokenType.COMMENT, SemanticTokenTypes.Comment ) + .put( ParserTokenType.AT_BASE, SemanticTokenTypes.Keyword ) + .put( ParserTokenType.AT_PREFIX, SemanticTokenTypes.Keyword ) + .put( ParserTokenType.SPARQL_BASE, SemanticTokenTypes.Keyword ) + .put( ParserTokenType.SPARQL_PREFIX, SemanticTokenTypes.Keyword ) + .put( ParserTokenType.A, SemanticTokenTypes.Keyword ) + .put( ParserTokenType.STRING, SemanticTokenTypes.String ) + .put( ParserTokenType.INTEGER, SemanticTokenTypes.Number ) + .put( ParserTokenType.DECIMAL, SemanticTokenTypes.Number ) + .put( ParserTokenType.DOUBLE, SemanticTokenTypes.Number ) + .put( ParserTokenType.BOOLEAN_LITERAL, SemanticTokenTypes.Keyword ) + .put( ParserTokenType.LANG_TAG, SemanticTokenTypes.Decorator ) + .put( ParserTokenType.PN_PREFIX, SemanticTokenTypes.Function ) + .put( ParserTokenType.PN_LOCAL, SemanticTokenTypes.Property ) + .put( ParserTokenType.SYMBOL_DOUBLE_CARET, SemanticTokenTypes.Decorator ) + .put( ParserTokenType.SYMBOL_FULL_STOP, SemanticTokenTypes.Decorator ) + .put( ParserTokenType.SYMBOL_SEMICOLON, SemanticTokenTypes.Decorator ) + .build(); + private final TreeSitterTurtleParserService parserService; + + private final Map tokenTypeIds = IntStream.range( 0, SUPPORTED_TOKEN_TYPES.getTokenTypes().size() ) + .boxed() + .collect( Collectors.toMap( i -> SUPPORTED_TOKEN_TYPES.getTokenTypes().get( i ), Function.identity() ) ); + private final Map tokenModifierTypeIds = IntStream.range( 0, SUPPORTED_TOKEN_TYPES.getTokenModifiers().size() ) + .boxed() + .collect( Collectors.toMap( i -> SUPPORTED_TOKEN_TYPES.getTokenModifiers().get( i ), Function.identity() ) ); + + public TurtleTokenService( final TreeSitterTurtleParserService parserService ) { + this.parserService = parserService; + } + + /** + * Represents a single token over a given range + * + * @param line the line where the token appears + * @param column the column in the line + * @param length the length of the token in characters + * @param tokenType the token type + * @param tokenModifiers the token modifiers bit set + */ + private record TokenRange( + int line, + int column, + int length, + int tokenType, + int tokenModifiers + ) {} + + /** + * Builds the SemanticTokens for a Document + * + * @param document the document + */ + public SemanticTokens buildSemanticTokens( final Document document ) { + final List tokenRanges = new ArrayList<>(); + final TSTree concreteSyntaxTree = parserService.apply( document ).concreteSyntaxTree(); + final Deque nodes = new ArrayDeque<>(); + TSNode node; + nodes.push( concreteSyntaxTree.getRootNode() ); + while ( !nodes.isEmpty() ) { + node = nodes.pop(); + for ( int i = 0; i < node.getChildCount(); i++ ) { + nodes.push( node.getChild( i ) ); + } + + final int tokenId = tokenIdForNode( node ); + if ( tokenId == -1 ) { + continue; + } + + final int line = node.getStartPoint().getRow(); + final int column = node.getStartPoint().getColumn(); + final int length = node.getEndByte() - node.getStartByte(); + tokenRanges.add( new TokenRange( line, column, length, tokenId, tokenModifierBitSetForNode( node, document ) ) ); + } + + return buildSemanticTokens( tokenRanges ); + } + + /** + * Builds the SemanticTokens for the given list of token ranges. In LSP, this is described as a list + * of integers. + * + * @param tokenRanges the input list of token ranges + * @see Semantic + * Tokens at LSP specification + * @return the SemanticTokens representation + */ + private SemanticTokens buildSemanticTokens( final List tokenRanges ) { + tokenRanges.sort( Comparator.comparingInt( TokenRange::line ).thenComparingInt( TokenRange::column ) ); + final List data = new ArrayList<>(); + int lastLine = -1; + int lastColumn = -1; + for ( final TokenRange tokenRange : tokenRanges ) { + final int line = tokenRange.line(); + final int column = tokenRange.column(); + if ( lastLine == -1 ) { + data.add( line ); + data.add( column ); + } else { + data.add( line - lastLine ); + data.add( lastLine == line ? column - lastColumn : column ); + } + data.add( tokenRange.length() ); + data.add( tokenRange.tokenType() ); + data.add( tokenRange.tokenModifiers() ); + lastLine = line; + lastColumn = column; + } + return new SemanticTokens( data ); + } + + /** + * Returns the tokenId for a given parser node, i.e., the index of the type of token in the + * SemanticTokenLegends.tokenTypes + * + * @param node the parser node + * @see TurtleTokenService#SUPPORTED_TOKEN_TYPES + * @return the corresponding tokenId + */ + private int tokenIdForNode( final TSNode node ) { + final String semanticToken = PARSER_TOKEN_TO_SEMANTIC_TOKEN.get( node.getGrammarType() ); + if ( semanticToken == null ) { + return -1; + } + + final Integer semanticTokenId = tokenTypeIds.get( semanticToken ); + if ( semanticTokenId == null ) { + LOG.error( "Trying to return unsupported token type for parser type {}", semanticToken ); + return -1; + } + return semanticTokenId; + } + + private int tokenModifierBitSetForNode( final TSNode node, final Document document ) { + int bitSet = 0; + if ( node.getGrammarType().equals( ParserTokenType.PN_PREFIX ) ) { + final String token = document.subSequence( node.getStartPoint().getRow(), node.getStartPoint().getColumn(), + node.getEndPoint().getRow(), node.getEndPoint().getColumn() ); + if ( token.equals( SammNs.SAMM.getShortForm() ) || token.equals( SammNs.SAMMC.getShortForm() ) ) { + bitSet = bitSet | ( 1 << tokenModifierTypeIds.get( SemanticTokenModifiers.DefaultLibrary ) ); + } + } + return bitSet; + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/turtle/TurtleSyntaxDiagnosticsService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/turtle/TurtleSyntaxDiagnosticsService.java new file mode 100644 index 000000000..c3fc479a4 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/turtle/TurtleSyntaxDiagnosticsService.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.turtle; + +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.eclipse.esmf.turtle.languageserver.diagnostic.DiagnosticReport; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnostic; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDiagnosticsService; +import org.eclipse.esmf.turtle.languageserver.diagnostic.TurtleDocumentDiagnostic; +import org.eclipse.esmf.turtle.languageserver.lsp.text.ParsedDocument; + +import org.treesitter.TSNode; + +public class TurtleSyntaxDiagnosticsService implements TurtleDiagnosticsService { + @Override + public DiagnosticReport defaultValidate( final ParsedDocument parsedDocument ) { + return new DiagnosticReport( checkNode( parsedDocument.concreteSyntaxTree().getRootNode(), + parsedDocument.sourceDocument().getUri() ).toList() ); + } + + private Stream checkNode( final TSNode node, final String sourceLocation ) { + return Stream.concat( node.isError() ? Stream.of( diagnosticForNode( node, sourceLocation ) ) : Stream.empty(), + IntStream.range( 0, node.getChildCount() ).boxed().map( node::getChild ) + .flatMap( child -> checkNode( child, sourceLocation ) ) ); + } + + private TurtleDocumentDiagnostic diagnosticForNode( final TSNode node, final String sourceLocation ) { + final String message; + if ( node.isMissing() ) { + message = "Syntax error: Missing '" + node.getGrammarType() + "'"; + } else if ( node.isExtra() ) { + message = node.getGrammarType().equals( "ERROR" ) + ? "Syntax error: Unexpected token" + : "Syntax error: Unexpected token '" + node.getGrammarType() + "'"; + } else { + message = "Syntax error"; + } + return new TurtleDocumentDiagnostic( message, + TurtleDiagnostic.TurtleCode.E0003, sourceLocation, + node.getStartPoint().getRow(), node.getStartPoint().getColumn(), + node.getEndPoint().getRow(), node.getEndPoint().getColumn() ); + } +} diff --git a/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/turtle/navigation/TurtlePrefixDefinitionService.java b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/turtle/navigation/TurtlePrefixDefinitionService.java new file mode 100644 index 000000000..d2be336e2 --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/java/org/eclipse/esmf/turtle/languageserver/turtle/navigation/TurtlePrefixDefinitionService.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.turtle.navigation; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import org.eclipse.esmf.turtle.languageserver.lsp.text.Document; + +import org.eclipse.lsp4j.Location; +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; + +public class TurtlePrefixDefinitionService { + private static final Pattern PREFIX_DECLARATION_PATTERN = Pattern.compile( + "^\\s*@prefix\\s+([A-Za-z][A-Za-z0-9_-]*)?:\\s*<[^>]*>\\s*\\.", + Pattern.CASE_INSENSITIVE + ); + + public Location findPrefixDeclaration( final Document document, final Position position ) { + final String content = document.getContent(); + final String prefix = findPrefixAtPosition( content, position ); + if ( prefix == null ) { + return null; + } + + final String[] lines = content.split( "\\R", -1 ); + for ( int line = 0; line < lines.length; line++ ) { + final Matcher matcher = PREFIX_DECLARATION_PATTERN.matcher( lines[line] ); + if ( !matcher.find() ) { + continue; + } + + final String declaredPrefix = matcher.group( 1 ); + final String normalizedPrefix = declaredPrefix == null ? "" : declaredPrefix; + if ( !normalizedPrefix.equals( prefix ) ) { + continue; + } + + return new Location( document.getUri(), new Range( new Position( line, 0 ), new Position( line, lines[line].length() ) ) ); + } + + return null; + } + + public String findPrefixAtPosition( final String content, final Position position ) { + int lineStart = 0; + int currentLine = 0; + while ( currentLine < position.getLine() && lineStart < content.length() ) { + if ( content.charAt( lineStart++ ) == '\n' ) { + currentLine++; + } + } + if ( currentLine != position.getLine() ) { + return null; + } + + int lineEnd = lineStart; + while ( lineEnd < content.length() && content.charAt( lineEnd ) != '\n' ) { + lineEnd++; + } + + final int character = Math.max( 0, Math.min( position.getCharacter(), lineEnd - lineStart ) ); + int offset = lineStart + character; + if ( offset > lineStart && ( offset == lineEnd || !isPrefixedNameChar( content.charAt( offset ) ) ) ) { + offset--; + } + if ( offset < lineStart || offset >= lineEnd || !isPrefixedNameChar( content.charAt( offset ) ) ) { + return null; + } + + int start = offset; + while ( start > lineStart && isPrefixedNameChar( content.charAt( start - 1 ) ) ) { + start--; + } + + int end = offset + 1; + while ( end < lineEnd && isPrefixedNameChar( content.charAt( end ) ) ) { + end++; + } + + final String token = content.substring( start, end ); + final int colonIndex = token.indexOf( ':' ); + if ( colonIndex < 0 || colonIndex == token.length() - 1 ) { + return null; + } + + final String prefix = token.substring( 0, colonIndex ); + final String localPart = token.substring( colonIndex + 1 ); + if ( localPart.isEmpty() ) { + return null; + } + + return prefix; + } + + private boolean isPrefixedNameChar( final char ch ) { + return Character.isLetterOrDigit( ch ) || ch == ':' || ch == '_' || ch == '-'; + } +} diff --git a/core/esmf-turtle-language-server/src/main/resources/logback.xml b/core/esmf-turtle-language-server/src/main/resources/logback.xml new file mode 100644 index 000000000..67b63924f --- /dev/null +++ b/core/esmf-turtle-language-server/src/main/resources/logback.xml @@ -0,0 +1,32 @@ + + + + + + System.err + + %cyan(%d{HH:mm:ss.SSS}) %highlight(%-5level) - %msg%n + + + + + + + + + + + + + diff --git a/core/esmf-turtle-language-server/src/test/java/org/eclipse/esmf/turtle/languageserver/lsp/text/RopeTest.java b/core/esmf-turtle-language-server/src/test/java/org/eclipse/esmf/turtle/languageserver/lsp/text/RopeTest.java new file mode 100644 index 000000000..7fa82c66d --- /dev/null +++ b/core/esmf-turtle-language-server/src/test/java/org/eclipse/esmf/turtle/languageserver/lsp/text/RopeTest.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ThreadLocalRandom; + +import org.junit.jupiter.api.Test; + +import net.jqwik.api.Arbitraries; +import net.jqwik.api.Arbitrary; +import net.jqwik.api.ForAll; +import net.jqwik.api.Property; +import net.jqwik.api.Provide; + +class RopeTest { + /** + * Controls which input strings should be used for tests. Ropes should work for any string, so we + * let Jqwik choose any kind of string. + * + * @return an arbitrary string + */ + @Provide + Arbitrary inputString() { + return Arbitraries.strings(); + } + + @Provide + Arbitrary basicString() { + return Arbitraries.strings().alpha().numeric().excludeChars( '\n' ); + } + + @Property + void ropesCanBeConcatenated( @ForAll( "inputString" ) final String string1, @ForAll( "inputString" ) final String string2 ) { + final Rope rope1 = new Rope( string1 ); + final Rope rope2 = new Rope( string2 ); + final Rope result = rope1.concat( rope2 ); + final Rope concatted = new Rope( string1 + string2 ); + assertThat( result ).isEqualTo( concatted ); + assertThat( result.toString() ).isEqualTo( string1 + string2 ); + assertThat( concatted.toString() ).isEqualTo( string1 + string2 ); + } + + @Property + void ropesCanBeSplit( @ForAll( "inputString" ) final String string ) { + final Rope rope = new Rope( string ); + for ( int i = 0; i <= rope.length(); i++ ) { + final Rope[] parts = rope.split( i ); + if ( parts[0] == null || parts[1] == null ) { + continue; + } + assertThat( parts[0].length() + parts[1].length() ).isEqualTo( rope.length() ); + assertThat( parts[0].value + parts[1].value ).isEqualTo( rope.toString() ); + } + } + + @Property + void ropesCanReturnSubsequences( @ForAll( "inputString" ) final String string ) { + final Rope rope = new Rope( string ); + for ( int i = 0; i <= rope.length(); i++ ) { + for ( int j = i; j <= rope.length(); j++ ) { + if ( i != j ) { + assertThat( rope.subSequence( i, j ).toString() ).isEqualTo( string.substring( i, j ) ); + } + } + } + } + + @Property + void ropesCanInsertStrings( @ForAll( "inputString" ) final String string1, @ForAll( "inputString" ) final String string2 ) { + final Rope rope1 = new Rope( string1 ); + final Rope rope2 = new Rope( string2 ); + for ( int i = 0; i <= rope1.length(); i++ ) { + final Rope ropeResult = rope1.insert( rope2, i ); + final String stringResult = string1.substring( 0, i ) + string2 + string1.substring( i ); + assertThat( ropeResult.toString() ).isEqualTo( stringResult ); + } + } + + @Property + void ropesCanBeRebalanced( @ForAll( "inputString" ) final String string1, @ForAll( "inputString" ) final String string2 ) { + final Rope rope1 = new Rope( string1 ); + final Rope rope2 = new Rope( string2 ); + + // Insert lots of stuff. This will degenerate the tree + Rope ropeResult = rope1; + for ( int j = 0; j <= 10; j++ ) { + if ( j > ropeResult.length() ) { + break; + } + ropeResult = ropeResult.insert( rope2, j ); + } + final Rope balanced = ropeResult.rebalance(); + assertThat( ropeResult ).isEqualTo( balanced ); + assertThat( ropeResult.toString() ).isEqualTo( balanced.toString() ); + } + + @Property + void ropesCanDeleteStrings( @ForAll( "inputString" ) final String string ) { + final Random random = ThreadLocalRandom.current(); + if ( string != null && string.isEmpty() ) { + return; + } + assertThat( string ).isNotNull(); + final int index = random.nextInt( 0, string.length() ); + final int length = random.nextInt( 0, string.length() - index ); + final Rope rope = new Rope( string ); + + final String stringResult = string.substring( 0, index ) + string.substring( index + length ); + final Rope result = rope.delete( index, length ); + assertThat( result.toString() ).isEqualTo( stringResult ); + } + + @Property + void testInputStreamAndToStringConsistency( @ForAll( "inputString" ) final String string ) throws IOException { + final Rope rope = new Rope( string ); + for ( final Charset encoding : List.of( StandardCharsets.UTF_8, StandardCharsets.UTF_16 ) ) { + final byte[] bytesFromToString = rope.toString().getBytes( encoding ); + try ( final InputStream inputStream = rope.inputStream( encoding ) ) { + final byte[] bytesFromInputStream = inputStream.readAllBytes(); + assertThat( bytesFromToString ) + .as( () -> "Compared string: " + string ) + .hasSameSizeAs( bytesFromInputStream ) + .isEqualTo( bytesFromInputStream ); + } + } + } + + @Test + void testSubSequence() { + final Rope rope = new Rope( "abc\ndef\n" ); + assertThat( rope.subSequence( 0, 3 ).toString() ).isEqualTo( "abc" ); + assertThat( rope.subSequence( 4, 7 ).toString() ).isEqualTo( "def" ); + } + + @Test + void testGetIndex() { + final Rope rope = new Rope( "abc\ndef\nghi\n" ); + assertThat( rope.getIndex( 0, 0 ) ).isEqualTo( 0 ); + assertThat( rope.getIndex( 0, 2 ) ).isEqualTo( 2 ); + assertThat( rope.getIndex( 1, 0 ) ).isEqualTo( 4 ); + assertThat( rope.getIndex( 1, 3 ) ).isEqualTo( 7 ); + assertThat( rope.getIndex( 2, 0 ) ).isEqualTo( 8 ); + assertThat( rope.getIndex( 2, 2 ) ).isEqualTo( 10 ); + + final Rope rope2 = new Rope( "\n\n" ); + assertThat( rope2.getIndex( 0, 0 ) ).isEqualTo( 0 ); + assertThat( rope2.getIndex( 1, 0 ) ).isEqualTo( 1 ); + assertThat( rope2.getIndex( 2, 0 ) ).isEqualTo( 2 ); + } + + @Property + void randomOperationsMatchStringBehavior( @ForAll( "inputString" ) final String initialString ) { + if ( initialString.isEmpty() ) { + return; + } + + final Random random = ThreadLocalRandom.current(); + final int operationCount = random.nextInt( 5, 15 ); + + Rope rope = new Rope( initialString ); + String string = initialString; + + enum Operation { + DELETE, INSERT, UPDATE + } + + for ( int i = 0; i < operationCount; i++ ) { + final Operation operation = Operation.values()[random.nextInt( 3 )]; + + try { + if ( string.isEmpty() ) { + // Can only insert if string is empty + final String toInsert = "test" + i; + rope = rope.insert( new Rope( toInsert ), 0 ); + assertThat( rope ).isNotNull(); + string = toInsert + string; + } else if ( operation == Operation.DELETE ) { + final int start = random.nextInt( string.length() ); + final int maxLength = string.length() - start; + final int length = maxLength > 0 ? random.nextInt( 1, maxLength + 1 ) : 0; + + if ( length > 0 ) { + rope = rope.delete( start, length ); + string = string.substring( 0, start ) + string.substring( start + length ); + } + } else if ( operation == Operation.INSERT ) { + final int index = random.nextInt( string.length() + 1 ); + final String toInsert = "ins" + i; + + rope = rope.insert( new Rope( toInsert ), index ); + string = string.substring( 0, index ) + toInsert + string.substring( index ); + } else { + // Update (replace) operation - delete then insert + final int start = random.nextInt( string.length() ); + final int maxLength = string.length() - start; + final int length = maxLength > 0 ? random.nextInt( 1, maxLength + 1 ) : 0; + final String replacement = "upd" + i; + + if ( length > 0 ) { + rope = rope.delete( start, length ).insert( new Rope( replacement ), start ); + string = string.substring( 0, start ) + replacement + string.substring( start + length ); + } + } + + assertThat( rope.toString() ) + .as( "After operation %d (type=%d)", i, operation ) + .isEqualTo( string ); + + } catch ( final Exception exception ) { + final String ropeLength = rope == null ? "?" : "" + rope.length(); + throw new AssertionError( "Failed at operation " + i + " with rope length " + ropeLength + + " and string length " + string.length() + ": " + exception.getMessage(), exception ); + } + } + + assertThat( rope.toString() ).isEqualTo( string ); + } + + @Test + void testUpdate() { + final Rope rope = new Rope( "\n" ); + final Rope result = rope.update( 0, 1, 0, 1, "X" ); + assertThat( result.toString() ).isEqualTo( "\nX" ); + + final Rope rope2 = new Rope( "abcd\nefgh" ); + final Rope result2 = rope2.update( 1, 1, 1, 3, "X" ); + assertThat( result2.toString() ).isEqualTo( "abcd\neX" ); + + final Rope rope3 = new Rope( "\nabcd" ); + final Rope result3 = rope3.update( 0, 0, 1, 3, "X" ); + assertThat( result3.toString() ).isEqualTo( "X" ); + + final Rope rope4 = new Rope( "\na" ); + final Rope result4 = rope4.update( 0, 0, 1, 0, "X" ); + assertThat( result4.toString() ).isEqualTo( "X" ); + } + + @Test + void testMixedOperationsWithKnownInput() { + // Test with a known input to ensure predictable behavior + String string = "line1\nline2\nline3\n"; + Rope rope = new Rope( string ); + + // Operation 1: Delete "line2\n" + rope = rope.delete( 6, 6 ); + string = string.substring( 0, 6 ) + string.substring( 12 ); + assertThat( rope.toString() ).isEqualTo( string ).isEqualTo( "line1\nline3\n" ); + + // Operation 2: Insert "inserted\n" at position 6 + rope = rope.insert( new Rope( "inserted\n" ), 6 ); + string = string.substring( 0, 6 ) + "inserted\n" + string.substring( 6 ); + assertThat( rope.toString() ).isEqualTo( string ).isEqualTo( "line1\ninserted\nline3\n" ); + + // Operation 3: Update using line/column (replace "inserted" with "modified") + rope = rope.update( 1, 0, 1, 7, "modified" ); + string = "line1\nmodified\nline3\n"; + assertThat( rope.toString() ).isEqualTo( string ); + + // Operation 4: Delete from middle of one line to middle of another + rope = rope.update( 0, 3, 2, 3, "X" ); + string = "linX3\n"; + assertThat( rope.toString() ).isEqualTo( string ); + } + + @Property + void randomDeletionsAndInsertionsPreserveLength( @ForAll( "inputString" ) final String initialString ) { + if ( initialString.length() < 5 ) { + return; + } + + final Random random = ThreadLocalRandom.current(); + Rope rope = new Rope( initialString ); + String string = initialString; + + // Perform 10 delete-insert pairs that should preserve total content + for ( int i = 0; i < 10; i++ ) { + final int index = random.nextInt( string.length() ); + final int deleteLength = Math.min( random.nextInt( 1, 4 ), string.length() - index ); + + // Delete + final String deleted = string.substring( index, index + deleteLength ); + rope = rope.delete( index, deleteLength ); + string = string.substring( 0, index ) + string.substring( index + deleteLength ); + + assertThat( rope.toString() ).isEqualTo( string ); + + // Re-insert at a different position + final int insertIndex = string.isEmpty() ? 0 : random.nextInt( string.length() + 1 ); + rope = rope.insert( new Rope( deleted ), insertIndex ); + string = string.substring( 0, insertIndex ) + deleted + string.substring( insertIndex ); + + assertThat( rope.toString() ).isEqualTo( string ); + } + + // Verify the characters are still all present (may be reordered) + assertThat( rope.length() ).isEqualTo( initialString.length() ); + } +} diff --git a/core/esmf-turtle-language-server/src/test/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TurtleParserServiceTest.java b/core/esmf-turtle-language-server/src/test/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TurtleParserServiceTest.java new file mode 100644 index 000000000..e34770efa --- /dev/null +++ b/core/esmf-turtle-language-server/src/test/java/org/eclipse/esmf/turtle/languageserver/lsp/text/TurtleParserServiceTest.java @@ -0,0 +1,386 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.turtle.languageserver.lsp.text; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.eclipse.esmf.treesitterturtle.TreeSitterUtil; + +import org.eclipse.lsp4j.Position; +import org.eclipse.lsp4j.Range; +import org.eclipse.lsp4j.TextDocumentContentChangeEvent; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.treesitter.TSNode; +import org.treesitter.TSTree; + +@SuppressWarnings( { "HttpUrlsUsage", "resource" } ) +class TurtleParserServiceTest { + private TreeSitterTurtleParserService parserService; + + @BeforeEach + void setUp() { + parserService = new TreeSitterTurtleParserService(); + } + + @Test + void testInitialParsing() { + final String content = """ + @prefix ex: . + + ex:subject ex:predicate ex:object . + """; + + final Document document = new Document( "test.ttl", content ); + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + + assertThat( tree ).isNotNull(); + final TSNode rootNode = tree.getRootNode(); + assertThat( rootNode ).isNotNull(); + assertThat( rootNode.getType() ).isEqualTo( "document" ); + assertThat( rootNode.hasError() ).isFalse(); + + final TSNode prefixDeclaration = rootNode.getChild( 0 ); + assertThat( prefixDeclaration ).isNotNull(); + assertThat( prefixDeclaration.getType() ).isEqualTo( "directive" ); + assertThat( prefixDeclaration.hasError() ).isFalse(); + + final TSNode prefixId = prefixDeclaration.getChild( 0 ); + assertThat( prefixId ).isNotNull(); + assertThat( prefixId.getType() ).isEqualTo( "prefix_id" ); + assertThat( prefixId.hasError() ).isFalse(); + + final TSNode namespace = prefixId.getChild( 0 ); + assertThat( namespace ).isNotNull(); + assertThat( namespace.getType() ).isEqualTo( "@prefix" ); + assertThat( namespace.hasError() ).isFalse(); + } + + @Test + void testEmptyDocument() { + final Document document = new Document( "empty.ttl", "" ); + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + + assertThat( tree ).isNotNull(); + final TSNode rootNode = tree.getRootNode(); + assertThat( rootNode ).isNotNull(); + assertThat( rootNode.getChildCount() ).isEqualTo( 0 ); + } + + @Test + void testSingleLineInsertion() { + final String initialContent = """ + @prefix ex: . + """; + + final Document document = new Document( "test.ttl", initialContent ); + final TSTree initialTree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( initialTree.getRootNode().hasError() ).isFalse(); + + final String newText = "\nex:subject ex:predicate ex:object ."; + applyChange( document, pos( 1, 0 ), pos( 1, 0 ), newText ); + + final TSTree updatedTree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( updatedTree ).isNotNull(); + assertThat( updatedTree.getRootNode().hasError() ).isFalse(); + + assertThat( document.getContent() ).contains( "ex:subject ex:predicate ex:object" ); + } + + @Test + void testMultiLineInsertion() { + final String initialContent = "@prefix ex: ."; + final Document document = new Document( "test.ttl", initialContent ); + + final String newText = """ + + ex:subject1 ex:predicate1 ex:object1 . + ex:subject2 ex:predicate2 ex:object2 . + ex:subject3 ex:predicate3 ex:object3 ."""; + + applyChange( document, pos( 0, initialContent.length() ), pos( 0, initialContent.length() ), newText ); + + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ) + .contains( "ex:subject1" ) + .contains( "ex:subject2" ) + .contains( "ex:subject3" ); + } + + @Test + void testDeletion() { + final String initialContent = """ + @prefix ex: . + + ex:subject ex:predicate ex:object . + ex:ToDelete ex:willBeDeleted ex:Value . + """; + + final Document document = new Document( "test.ttl", initialContent ); + parserService.apply( document ).concreteSyntaxTree(); + + applyChange( document, pos( 3, 0 ), pos( 3, 38 ), "" ); + + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).doesNotContain( "ex:ToDelete" ); + assertThat( document.getContent() ).contains( "ex:subject ex:predicate ex:object" ); + } + + @Test + void testReplacement() { + final String initialContent = """ + @prefix ex: . + + ex:subject ex:predicate ex:OldObject . + """; + + final Document document = new Document( "test.ttl", initialContent ); + parserService.apply( document ).concreteSyntaxTree(); + + final String replacement = "NewObject"; + applyChange( document, pos( 2, 27 ), pos( 2, 35 ), replacement ); + + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + if ( tree.getRootNode().hasError() ) { + printDocumentAndTree( document, tree ); + } + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).doesNotContain( "OldObject" ); + assertThat( document.getContent() ).contains( "NewObject" ); + } + + @Test + void testMultipleSequentialEdits() { + final Document document = new Document( "test.ttl", "" ); + + final String text1 = "@prefix ex: ."; + applyChange( document, pos( 0, 0 ), pos( 0, 0 ), text1 ); + + TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + + final String text2 = "\n\nex:subject1 ex:predicate1 ex:object1 ."; + applyChange( document, pos( 0, 35 ), pos( 0, 35 ), text2 ); + + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).contains( "ex:subject1" ); + + final String text3 = "\nex:subject2 ex:predicate2 ex:object2 ."; + applyChange( document, pos( 2, 38 ), pos( 2, 38 ), text3 ); + + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).contains( "ex:subject1" ) + .contains( "ex:subject2" ); + + applyChange( document, pos( 2, 0 ), pos( 2, 38 ), "" ); + + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).doesNotContain( "ex:subject1" ); + assertThat( document.getContent() ).contains( "ex:subject2" ); + } + + @Test + void testEditAcrossMultipleLines() { + final String initialContent = """ + @prefix ex: . + + ex:subject + ex:predicate1 ex:object1 ; + ex:predicate2 ex:object2 . + """; + + final Document document = new Document( "test.ttl", initialContent ); + parserService.apply( document ).concreteSyntaxTree(); + + final String replacement = "ex:newPredicate"; + applyChange( document, pos( 3, 2 ), pos( 4, 14 ), replacement ); + + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).contains( "ex:newPredicate" ); + assertThat( document.getContent() ).doesNotContain( "ex:predicate1" ); + } + + @Test + void testParseValidSyntax() { + final String initialContent = """ + # Document top comment + @prefix ex: . + + # Comment on subject + ex:subject + ex:predicate1 ex:object1 ; + ex:predicate2 123 ; + ex:predicate3 true ; + # comment on string + ex:predicate4 "some string" ; + ex:predicate5 "some langString"@en ; + ex:predicate6 "123"^^xsd:decimal ; + ex:predicate7 . + + a rdf:type . + + ex:subject2 ex:bla [ + ex:blub true ; + ] . + + ex:subject3 ex:bla ( 1 2 3 ) . + + ex:subject4 ex:bla \"""hello\""" . + + ex:subject5 ex:bla '''hello''' . + """; + + final Document document = new Document( "test.ttl", initialContent ); + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + } + + @Test + void testPrefixAddition() { + final String initialContent = "ex:subject ex:predicate ex:object ."; + + final Document document = new Document( "test.ttl", initialContent ); + parserService.apply( document ).concreteSyntaxTree(); + + final String prefix = "@prefix ex: .\n\n"; + applyChange( document, pos( 0, 0 ), pos( 0, 0 ), prefix ); + + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).startsWith( "@prefix ex:" ); + } + + @Test + void testComplexEditSequence() { + final Document document = new Document( "test.ttl", "" ); + TSTree tree; + + applyChange( document, pos( 0, 0 ), pos( 0, 0 ), + "@base .\n@prefix ex: .\n\n" ); + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + + applyChange( document, pos( 3, 0 ), pos( 3, 0 ), + "ex:subject ex:predicate ex:object ." ); + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + + applyChange( document, pos( 3, 33 ), pos( 3, 34 ), + " ;\n ex:predicate2 ex:object2 ;\n ex:predicate3 ex:object3 ." ); + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + + final String content = document.getContent(); + final int predicateStart = content.indexOf( "ex:predicate2" ); + final int line = content.substring( 0, predicateStart ).split( "\n" ).length - 1; + final int col = predicateStart - content.lastIndexOf( '\n', predicateStart ) - 1; + + applyChange( document, pos( line, col ), pos( line, col + 12 ), "ex:modified" ); + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).contains( "ex:modified" ); + assertThat( document.getContent() ).doesNotContain( "ex:predicate2" ); + + assertThat( document.getContent() ) + .contains( "@base" ) + .contains( "@prefix" ) + .contains( "ex:subject" ) + .contains( "ex:predicate" ) + .contains( "ex:predicate3" ) + .contains( "ex:modified" ); + } + + @Test + void testInvalidSyntaxHandling() { + final String initialContent = "@prefix ex: ."; + final Document document = new Document( "test.ttl", initialContent ); + + TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + + final String invalidText = "\n\nthis is not valid turtle syntax @#$%"; + applyChange( document, pos( 0, 35 ), pos( 0, 35 ), invalidText ); + + tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode() ).isNotNull(); + // Tree-sitter should still parse it, but with errors + assertThat( tree.getRootNode().hasError() ).isTrue(); + } + + @Test + void testFullDocumentChange() { + final String initialContent = "@prefix ex: ."; + final Document document = new Document( "test.ttl", initialContent ); + + parserService.apply( document ).concreteSyntaxTree(); + final String newContent = """ + @prefix rdf: . + @prefix rdfs: . + + rdf:type rdf:type rdf:Property . + """; + + final TextDocumentContentChangeEvent change = new TextDocumentContentChangeEvent(); + change.setText( newContent ); + final Document newDocument = new Document( "test.ttl", newContent ); + parserService.onChange( newDocument, change ); + + final TSTree tree = parserService.apply( newDocument ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( newDocument.getContent() ).contains( "rdfs:" ); + } + + @Test + void testWhitespaceOnlyChanges() { + final String initialContent = "ex:subject ex:predicate ex:object."; + final Document document = new Document( "test.ttl", initialContent ); + final TSTree oldTree = parserService.apply( document ).concreteSyntaxTree(); + final Range range = new Range( pos( 0, 33 ), pos( 0, 33 ) ); + final TextDocumentContentChangeEvent change = new TextDocumentContentChangeEvent( range, " " ); + document.update( range, " " ); + parserService.onChange( document, change ); + + final TSTree tree = parserService.apply( document ).concreteSyntaxTree(); + assertThat( tree.getRootNode().hasError() ).isFalse(); + assertThat( document.getContent() ).endsWith( "object ." ); + assertThat( oldTree.getRootNode().toString() ).isEqualTo( tree.getRootNode().toString() ); + } + + void printDocumentAndTree( final Document document, final TSTree tree ) { + System.out.println( "Document" ); + System.out.println( "--------" ); + System.out.println( document.getContent() ); + System.out.println(); + System.out.println( "Tree" ); + System.out.println( "----" ); + System.out.println( TreeSitterUtil.print( tree ) ); + } + + private Position pos( final int line, final int character ) { + return new Position( line, character ); + } + + private void applyChange( final Document document, final Position start, final Position end, final String text ) { + final Range range = new Range( start, end ); + final TextDocumentContentChangeEvent change = new TextDocumentContentChangeEvent( range, text ); + document.update( range, text ); + parserService.onChange( document, change ); + } +} diff --git a/core/esmf-util/pom.xml b/core/esmf-util/pom.xml new file mode 100644 index 000000000..ec667bc66 --- /dev/null +++ b/core/esmf-util/pom.xml @@ -0,0 +1,63 @@ + + + + + + org.eclipse.esmf + esmf-sdk-parent + DEV-SNAPSHOT + ../../pom.xml + + 4.0.0 + + esmf-util + ESMF Util + jar + + + + org.eclipse.esmf + esmf-aspect-meta-model-interface + + + org.slf4j + slf4j-api + + + io.soabase.record-builder + record-builder-processor + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.soabase.record-builder + record-builder-processor + ${record-builder.version} + + + + + + + diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/Download.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/download/Download.java similarity index 76% rename from core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/Download.java rename to core/esmf-util/src/main/java/org/eclipse/esmf/util/download/Download.java index 1392b41d7..d0df5d54c 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/Download.java +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/download/Download.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Robert Bosch Manufacturing Solutions GmbH + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH * * See the AUTHORS file(s) distributed with this work for additional * information regarding authorship. @@ -11,7 +11,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -package org.eclipse.esmf.aspectmodel.resolver; +package org.eclipse.esmf.util.download; import java.io.File; import java.io.FileOutputStream; @@ -25,8 +25,6 @@ import java.util.Map; import java.util.stream.Stream; -import org.eclipse.esmf.aspectmodel.resolver.exceptions.ModelResolutionException; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -36,10 +34,19 @@ */ public class Download { private static final Logger LOG = LoggerFactory.getLogger( Download.class ); - private final ProxyConfig proxyConfig; + private final Config config; + + public record Config( + ProxyConfig proxyConfig, + Duration timeout + ) {} public Download( final ProxyConfig proxyConfig ) { - this.proxyConfig = proxyConfig; + this( new Config( proxyConfig, Duration.ofSeconds( 10 ) ) ); + } + + public Download( final Config config ) { + this.config = config; } public Download() { @@ -69,21 +76,23 @@ public HttpResponse downloadFileAsResponse( final URL fileUrl, final Map final HttpClient.Builder clientBuilder = HttpClient.newBuilder() .version( HttpClient.Version.HTTP_1_1 ) .followRedirects( HttpClient.Redirect.ALWAYS ) - .connectTimeout( Duration.ofSeconds( 10 ) ); - proxyConfig.proxy().ifPresent( clientBuilder::proxy ); - proxyConfig.authenticator().ifPresent( clientBuilder::authenticator ); + .connectTimeout( config.timeout() ); + config.proxyConfig().proxy().ifPresent( clientBuilder::proxy ); + config.proxyConfig().authenticator().ifPresent( clientBuilder::authenticator ); final HttpClient client = clientBuilder.build(); final String[] headersArray = headers.entrySet().stream() .flatMap( entry -> Stream.of( entry.getKey(), entry.getValue() ) ) .toList() .toArray( new String[0] ); - final HttpRequest request = HttpRequest.newBuilder() - .uri( fileUrl.toURI() ) - .headers( headersArray ) - .build(); + final HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri( fileUrl.toURI() ); + if ( headersArray.length > 0 ) { + builder.headers( headersArray ); + } + final HttpRequest request = builder.build(); return client.send( request, HttpResponse.BodyHandlers.ofByteArray() ); } catch ( final InterruptedException | URISyntaxException | IOException exception ) { - throw new ModelResolutionException( "Could not retrieve " + fileUrl, exception ); + throw new DownloadException( "Could not retrieve " + fileUrl, exception ); } } @@ -114,10 +123,10 @@ public File downloadFile( final URL fileUrl, final Map headers, if ( httpResponse.statusCode() >= 200 && httpResponse.statusCode() < 300 ) { outputStream.write( httpResponse.body() ); } else { - throw new ModelResolutionException( "Could not download file (status code: " + httpResponse.statusCode() + ")" ); + throw new DownloadException( "Could not download file (status code: " + httpResponse.statusCode() + ")" ); } } catch ( final IOException exception ) { - throw new ModelResolutionException( "Could not write file " + outputFile, exception ); + throw new DownloadException( "Could not write file " + outputFile, exception ); } LOG.info( "Downloaded {} to local file {}", fileUrl.getPath(), outputFile ); diff --git a/core/esmf-util/src/main/java/org/eclipse/esmf/util/download/DownloadException.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/download/DownloadException.java new file mode 100644 index 000000000..243432a91 --- /dev/null +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/download/DownloadException.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.util.download; + +public class DownloadException extends RuntimeException { + public DownloadException( final String message ) { + super( message ); + } + + public DownloadException( final String message, final Throwable cause ) { + super( message, cause ); + } +} diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/ProxyConfig.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/download/ProxyConfig.java similarity index 95% rename from core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/ProxyConfig.java rename to core/esmf-util/src/main/java/org/eclipse/esmf/util/download/ProxyConfig.java index c2cfd702a..0eb557233 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/ProxyConfig.java +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/download/ProxyConfig.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2024 Robert Bosch Manufacturing Solutions GmbH + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH * * See the AUTHORS file(s) distributed with this work for additional * information regarding authorship. @@ -11,7 +11,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -package org.eclipse.esmf.aspectmodel.resolver; +package org.eclipse.esmf.util.download; import java.net.Authenticator; import java.net.InetSocketAddress; diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/BinaryLauncher.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/BinaryLauncher.java similarity index 84% rename from core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/BinaryLauncher.java rename to core/esmf-util/src/main/java/org/eclipse/esmf/util/process/BinaryLauncher.java index 506e77e9f..affe96f61 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/BinaryLauncher.java +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/BinaryLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH * * See the AUTHORS file(s) distributed with this work for additional * information regarding authorship. @@ -11,7 +11,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -package org.eclipse.esmf.aspectmodel.resolver.process; +package org.eclipse.esmf.util.process; import java.io.File; import java.util.List; diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/ExecutableJarLauncher.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ExecutableJarLauncher.java similarity index 93% rename from core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/ExecutableJarLauncher.java rename to core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ExecutableJarLauncher.java index de2fe76fe..8fbf98aa4 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/ExecutableJarLauncher.java +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ExecutableJarLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH * * See the AUTHORS file(s) distributed with this work for additional * information regarding authorship. @@ -11,7 +11,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -package org.eclipse.esmf.aspectmodel.resolver.process; +package org.eclipse.esmf.util.process; import java.io.File; import java.util.ArrayList; diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/OsProcessLauncher.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/OsProcessLauncher.java similarity index 95% rename from core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/OsProcessLauncher.java rename to core/esmf-util/src/main/java/org/eclipse/esmf/util/process/OsProcessLauncher.java index 02f3100bf..9b37a276a 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/OsProcessLauncher.java +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/OsProcessLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH * * See the AUTHORS file(s) distributed with this work for additional * information regarding authorship. @@ -11,7 +11,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -package org.eclipse.esmf.aspectmodel.resolver.process; +package org.eclipse.esmf.util.process; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -28,8 +28,6 @@ import java.util.concurrent.Future; import java.util.stream.Collectors; -import org.eclipse.esmf.aspectmodel.resolver.exceptions.ProcessExecutionException; - import org.apache.commons.io.IOUtils; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/exceptions/ProcessExecutionException.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ProcessExecutionException.java similarity index 83% rename from core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/exceptions/ProcessExecutionException.java rename to core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ProcessExecutionException.java index 766f853ad..5162dfb3c 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/exceptions/ProcessExecutionException.java +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ProcessExecutionException.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH * * See the AUTHORS file(s) distributed with this work for additional * information regarding authorship. @@ -11,7 +11,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -package org.eclipse.esmf.aspectmodel.resolver.exceptions; +package org.eclipse.esmf.util.process; public class ProcessExecutionException extends RuntimeException { public ProcessExecutionException( final String message ) { diff --git a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/ProcessLauncher.java b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ProcessLauncher.java similarity index 95% rename from core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/ProcessLauncher.java rename to core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ProcessLauncher.java index 5a96519a5..5bd1d1864 100644 --- a/core/esmf-aspect-meta-model-java/src/main/java/org/eclipse/esmf/aspectmodel/resolver/process/ProcessLauncher.java +++ b/core/esmf-util/src/main/java/org/eclipse/esmf/util/process/ProcessLauncher.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Robert Bosch Manufacturing Solutions GmbH + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH * * See the AUTHORS file(s) distributed with this work for additional * information regarding authorship. @@ -11,7 +11,7 @@ * SPDX-License-Identifier: MPL-2.0 */ -package org.eclipse.esmf.aspectmodel.resolver.process; +package org.eclipse.esmf.util.process; import java.io.File; import java.util.Arrays; @@ -21,8 +21,6 @@ import java.util.function.Consumer; import java.util.function.Function; -import org.eclipse.esmf.aspectmodel.resolver.exceptions.ProcessExecutionException; - /** * This class abstracts running a "process", i.e. running a program by providing its arguments, * optional stdin and its working directory, and representing the output using exit status and diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..560fc2731 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "maven-build-dependencies", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "maven-build-dependencies", + "version": "1.0.0", + "license": "Copyright Robert Bosch Manufacturing Solutions GmbH, Germany. All rights reserved.", + "dependencies": { + "tree-sitter-cli": "^0.25.10" + } + }, + "node_modules/tree-sitter-cli": { + "version": "0.25.10", + "resolved": "https://registry.npmjs.org/tree-sitter-cli/-/tree-sitter-cli-0.25.10.tgz", + "integrity": "sha512-KoebQguKMCIghisEOdA372TIbrUl0kdnfZ9YQIBRAeOvNSKe85XbU4LuFW7hduRUwJj0rAG7pX5wo9sZhbBF1g==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "tree-sitter": "cli.js" + }, + "engines": { + "node": ">=12.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..71f24351f --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "maven-build-dependencies", + "version": "1.0.0", + "description": "Package containing all NPM packages for Maven build. No npm install required, fully integrated into maven build.", + "license": "MPL-2.0", + "author": "Robert Bosch Manufacturing Solutions GmbH", + "type": "commonjs", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "tree-sitter-cli": "^0.25.10" + } +} diff --git a/pom.xml b/pom.xml index 2b012bf84..da284eb2a 100644 --- a/pom.xml +++ b/pom.xml @@ -49,6 +49,7 @@ + core/esmf-util core/esmf-aspect-meta-model-interface core/esmf-aspect-meta-model-java core/esmf-aspect-model-aas-generator @@ -63,6 +64,8 @@ core/esmf-aspect-model-urn core/esmf-aspect-model-validator core/esmf-aspect-static-meta-model-java + core/esmf-tree-sitter-turtle + core/esmf-turtle-language-server core/esmf-test-aspect-models core/esmf-test-resources tools/esmf-aspect-model-maven-plugin @@ -81,11 +84,17 @@ 1.15.2 3.4.1 1.12.0 + 1.11 + + org.eclipse.esmf + esmf-util + ${project.version} + org.eclipse.esmf esmf-aspect-meta-model-interface @@ -116,6 +125,11 @@ esmf-aspect-model-github-resolver ${project.version} + + org.eclipse.esmf + esmf-turtle-language-server + ${project.version} + org.eclipse.esmf esmf-aspect-model-jackson @@ -156,6 +170,11 @@ esmf-aspect-static-meta-model-java ${project.version} + + org.eclipse.esmf + esmf-tree-sitter-turtle + ${project.version} + org.eclipse.esmf esmf-test-aspect-models @@ -234,6 +253,12 @@ + + org.tukaani + xz + ${xz.version} + + org.apache.maven.shared diff --git a/tools/esmf-aspect-model-maven-plugin/src/main/java/org/eclipse/esmf/aspectmodel/AspectModelMojo.java b/tools/esmf-aspect-model-maven-plugin/src/main/java/org/eclipse/esmf/aspectmodel/AspectModelMojo.java index 58fb09059..72c3c6586 100644 --- a/tools/esmf-aspect-model-maven-plugin/src/main/java/org/eclipse/esmf/aspectmodel/AspectModelMojo.java +++ b/tools/esmf-aspect-model-maven-plugin/src/main/java/org/eclipse/esmf/aspectmodel/AspectModelMojo.java @@ -36,7 +36,7 @@ import org.eclipse.esmf.aspectmodel.loader.AspectModelLoader; import org.eclipse.esmf.aspectmodel.resolver.FileSystemStrategy; import org.eclipse.esmf.aspectmodel.resolver.GithubRepository; -import org.eclipse.esmf.aspectmodel.resolver.ProxyConfig; +import org.eclipse.esmf.util.download.ProxyConfig; import org.eclipse.esmf.aspectmodel.resolver.ResolutionStrategy; import org.eclipse.esmf.aspectmodel.resolver.github.GitHubStrategy; import org.eclipse.esmf.aspectmodel.resolver.github.GithubModelSourceConfig; diff --git a/tools/samm-cli/pom.xml b/tools/samm-cli/pom.xml index 9fc6d451e..0e596c902 100644 --- a/tools/samm-cli/pom.xml +++ b/tools/samm-cli/pom.xml @@ -45,6 +45,10 @@ org.eclipse.esmf esmf-aspect-model-github-resolver + + org.eclipse.esmf + esmf-turtle-language-server + com.fasterxml.jackson.core jackson-databind @@ -241,12 +245,11 @@ *:* - module-info.class + + **/module-info.class META-INF/* META-INF/sisu/javax.inject.Named META-INF/plexus/components.xml - META-INF.versions*/** - META-INF/versions*/** META-INF/maven/** plugin.xml about.html diff --git a/tools/samm-cli/src/main/java/org/eclipse/esmf/SammCli.java b/tools/samm-cli/src/main/java/org/eclipse/esmf/SammCli.java index 38a3b83a8..21b084ad9 100644 --- a/tools/samm-cli/src/main/java/org/eclipse/esmf/SammCli.java +++ b/tools/samm-cli/src/main/java/org/eclipse/esmf/SammCli.java @@ -32,6 +32,7 @@ import org.eclipse.esmf.exception.CommandException; import org.eclipse.esmf.exception.SubCommandException; import org.eclipse.esmf.importer.ImportCommand; +import org.eclipse.esmf.lsp.LspCommand; import org.eclipse.esmf.namespacepackage.PackageCommand; import org.eclipse.esmf.namespacepackage.PackageExportCommand; import org.eclipse.esmf.namespacepackage.PackageImportCommand; @@ -106,6 +107,7 @@ public SammCli() { .addSubcommand( new AasCommand() ) .addSubcommand( new PackageCommand() ) .addSubcommand( new ImportCommand() ) + .addSubcommand( new LspCommand() ) .setCaseInsensitiveEnumValuesAllowed( true ) .setExecutionStrategy( LoggingMixin::executionStrategy ); initialCommandLine.getHelpSectionMap().put( SECTION_KEY_COMMAND_LIST, new CustomCommandListRenderer() ); diff --git a/tools/samm-cli/src/main/java/org/eclipse/esmf/lsp/LspCommand.java b/tools/samm-cli/src/main/java/org/eclipse/esmf/lsp/LspCommand.java new file mode 100644 index 000000000..5094326c3 --- /dev/null +++ b/tools/samm-cli/src/main/java/org/eclipse/esmf/lsp/LspCommand.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +package org.eclipse.esmf.lsp; + +import org.eclipse.esmf.AbstractCommand; +import org.eclipse.esmf.LoggingMixin; +import org.eclipse.esmf.turtle.languageserver.TurtleLanguageServer; + +import picocli.CommandLine; + +@CommandLine.Command( + name = LspCommand.COMMAND_NAME, + description = "Launch Turtle Language Server", + subcommands = { + CommandLine.HelpCommand.class + }, + headerHeading = "@|bold Usage|@:%n%n", + descriptionHeading = "%n@|bold Description|@:%n%n", + parameterListHeading = "%n@|bold Parameters|@:%n", + optionListHeading = "%n@|bold Options|@:%n" ) +public class LspCommand extends AbstractCommand { + public static final String COMMAND_NAME = "lsp"; + + @CommandLine.Mixin + private LoggingMixin loggingMixin; + + @CommandLine.Option( + names = { "-p", "--port" }, + description = "Port to listen on when using socket communication (default: ${DEFAULT-VALUE})" ) + int port = TurtleLanguageServer.DEFAULT_PORT; + + @CommandLine.Option( + names = { "-s", "--stdio" }, + description = "Use standard input/output for communication instead of sockets" ) + boolean useStdio = false; + + @Override + public void run() { + if ( useStdio ) { + TurtleLanguageServer.launchForStdio(); + return; + } + TurtleLanguageServer.launchForSocket( port ); + } +} diff --git a/tools/samm-cli/src/main/java/org/eclipse/esmf/namespacepackage/PackageImportCommand.java b/tools/samm-cli/src/main/java/org/eclipse/esmf/namespacepackage/PackageImportCommand.java index dc0784086..f9b9725ab 100644 --- a/tools/samm-cli/src/main/java/org/eclipse/esmf/namespacepackage/PackageImportCommand.java +++ b/tools/samm-cli/src/main/java/org/eclipse/esmf/namespacepackage/PackageImportCommand.java @@ -30,7 +30,7 @@ import org.eclipse.esmf.aspectmodel.edit.AspectChangeManagerConfig; import org.eclipse.esmf.aspectmodel.edit.AspectChangeManagerConfigBuilder; import org.eclipse.esmf.aspectmodel.edit.Change; -import org.eclipse.esmf.aspectmodel.resolver.Download; +import org.eclipse.esmf.util.download.Download; import org.eclipse.esmf.aspectmodel.resolver.NamespacePackage; import org.eclipse.esmf.aspectmodel.resolver.fs.ModelsRoot; import org.eclipse.esmf.exception.SubCommandException; diff --git a/tools/samm-cli/src/test/java/org/eclipse/esmf/MainClassProcessLauncher.java b/tools/samm-cli/src/test/java/org/eclipse/esmf/MainClassProcessLauncher.java index f63a02cd9..db3c6b288 100644 --- a/tools/samm-cli/src/test/java/org/eclipse/esmf/MainClassProcessLauncher.java +++ b/tools/samm-cli/src/test/java/org/eclipse/esmf/MainClassProcessLauncher.java @@ -19,8 +19,8 @@ import java.util.function.Predicate; import java.util.stream.Stream; -import org.eclipse.esmf.aspectmodel.resolver.process.OsProcessLauncher; -import org.eclipse.esmf.aspectmodel.resolver.process.ProcessLauncher; +import org.eclipse.esmf.util.process.OsProcessLauncher; +import org.eclipse.esmf.util.process.ProcessLauncher; /** * A {@link ProcessLauncher} that executes the static main(String[] args) function of a given class diff --git a/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliAbstractTest.java b/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliAbstractTest.java index af7a9e0b4..9c56016e7 100644 --- a/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliAbstractTest.java +++ b/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliAbstractTest.java @@ -23,7 +23,7 @@ import java.nio.file.Path; import java.util.stream.Stream; -import org.eclipse.esmf.aspectmodel.resolver.process.ProcessLauncher; +import org.eclipse.esmf.util.process.ProcessLauncher; import org.eclipse.esmf.samm.KnownVersion; import org.eclipse.esmf.test.InvalidTestAspect; import org.eclipse.esmf.test.OrderingTestAspect; diff --git a/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliIntegrationTest.java b/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliIntegrationTest.java index 17ca8aa94..2e9b84c9a 100644 --- a/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliIntegrationTest.java +++ b/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliIntegrationTest.java @@ -18,9 +18,9 @@ import java.io.File; import java.util.List; -import org.eclipse.esmf.aspectmodel.resolver.exceptions.ProcessExecutionException; -import org.eclipse.esmf.aspectmodel.resolver.process.ExecutableJarLauncher; -import org.eclipse.esmf.aspectmodel.resolver.process.ProcessLauncher; +import org.eclipse.esmf.util.process.ProcessExecutionException; +import org.eclipse.esmf.util.process.ExecutableJarLauncher; +import org.eclipse.esmf.util.process.ProcessLauncher; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestInstance; diff --git a/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliTest.java b/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliTest.java index 1be106f6d..530e88f35 100644 --- a/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliTest.java +++ b/tools/samm-cli/src/test/java/org/eclipse/esmf/SammCliTest.java @@ -29,8 +29,8 @@ import java.util.regex.Pattern; import org.eclipse.esmf.aspectmodel.generator.jsonschema.AspectModelJsonSchemaGenerator; -import org.eclipse.esmf.aspectmodel.resolver.process.ProcessLauncher; -import org.eclipse.esmf.aspectmodel.resolver.process.ProcessLauncher.ExecutionResult; +import org.eclipse.esmf.util.process.ProcessLauncher; +import org.eclipse.esmf.util.process.ProcessLauncher.ExecutionResult; import org.eclipse.esmf.aspectmodel.urn.AspectModelUrn; import org.eclipse.esmf.aspectmodel.validation.InvalidSyntaxViolation; import org.eclipse.esmf.aspectmodel.validation.ProcessingViolation; diff --git a/tools/samm-cli/src/test/resources/logback.xml b/tools/samm-cli/src/test/resources/logback.xml index 9fdc8b5fe..6529242b6 100644 --- a/tools/samm-cli/src/test/resources/logback.xml +++ b/tools/samm-cli/src/test/resources/logback.xml @@ -11,6 +11,7 @@ ~ SPDX-License-Identifier: MPL-2.0 --> + System.out