diff --git a/src/de/inetsoftware/jwebassembly/binary/BinaryModuleWriter.java b/src/de/inetsoftware/jwebassembly/binary/BinaryModuleWriter.java index ad233c46..6cd140e0 100644 --- a/src/de/inetsoftware/jwebassembly/binary/BinaryModuleWriter.java +++ b/src/de/inetsoftware/jwebassembly/binary/BinaryModuleWriter.java @@ -34,6 +34,7 @@ import de.inetsoftware.jwebassembly.module.ModuleWriter; import de.inetsoftware.jwebassembly.module.ValueTypeConvertion; import de.inetsoftware.jwebassembly.module.WasmTarget; +import de.inetsoftware.jwebassembly.sourcemap.SourceMapWriter; import de.inetsoftware.jwebassembly.wasm.AnyType; import de.inetsoftware.jwebassembly.wasm.ArrayOperator; import de.inetsoftware.jwebassembly.wasm.NamedStorageType; @@ -82,6 +83,8 @@ public class BinaryModuleWriter extends ModuleWriter implements InstructionOpcod private int exceptionSignatureIndex = -1; + private String javaSourceFile; + /** * Create new instance. * @@ -202,12 +205,16 @@ private void writeCodeSection() throws IOException { if( size == 0 ) { return; } + SourceMapWriter sourceMap = new SourceMapWriter(); WasmOutputStream stream = new WasmOutputStream(); stream.writeVaruint32( size ); for( Function func : functions.values() ) { func.functionsStream.writeTo( stream ); } wasm.writeSection( SectionType.Code, stream ); + if( createSourceMap ) { + sourceMap.generate( target.getSourceMapOutput() ); + } } /** @@ -358,9 +365,7 @@ protected void writeExport( FunctionName name, String exportName ) throws IOExce @Override protected void writeMethodStart( FunctionName name, String sourceFile ) throws IOException { function = getFunction( name ); - if( createSourceMap ) { - function.sourceFile = sourceFile; - } + this.javaSourceFile = sourceFile; functionType = new FunctionTypeEntry(); codeStream.reset(); locals.clear(); @@ -409,7 +414,7 @@ protected void writeMethodParamFinish() throws IOException { @Override protected void markCodePosition( int javaCodePosition ) { if( createSourceMap ) { - function.markCodePosition( codeStream.size(), javaCodePosition ); + function.markCodePosition( codeStream.size(), javaCodePosition, javaSourceFile ); } } diff --git a/src/de/inetsoftware/jwebassembly/binary/Function.java b/src/de/inetsoftware/jwebassembly/binary/Function.java index 0605f86d..6952f8a2 100644 --- a/src/de/inetsoftware/jwebassembly/binary/Function.java +++ b/src/de/inetsoftware/jwebassembly/binary/Function.java @@ -16,8 +16,11 @@ package de.inetsoftware.jwebassembly.binary; import java.io.IOException; +import java.util.ArrayList; import java.util.List; +import de.inetsoftware.jwebassembly.sourcemap.SourceMapping; + /** * An entry in the function section of the WebAssembly. * @@ -25,15 +28,15 @@ */ class Function extends SectionEntry { - int id; + int id; - int typeId; + int typeId; - List paramNames; + List paramNames; - WasmOutputStream functionsStream; + WasmOutputStream functionsStream; - String sourceFile; + ArrayList sourceMappings; /** * {@inheritDoc} @@ -48,12 +51,15 @@ void writeSectionEntry( WasmOutputStream stream ) throws IOException { * * @param streamPosition * the position in the function stream - * @param javaCodePosition + * @param javaSourceLine * the position in the Java Source file + * @param sourceFileName + * the name of the Java source file */ - void markCodePosition( int streamPosition, int javaCodePosition ) { - if( sourceFile != null ) { - // TODO Auto-generated method stub + void markCodePosition( int streamPosition, int javaSourceLine, String sourceFileName ) { + if( sourceMappings == null ) { + sourceMappings = new ArrayList<>(); } + sourceMappings.add( new SourceMapping( streamPosition, javaSourceLine, sourceFileName ) ); } } diff --git a/src/de/inetsoftware/jwebassembly/module/WasmTarget.java b/src/de/inetsoftware/jwebassembly/module/WasmTarget.java index a8a80ac1..ae8cffc8 100644 --- a/src/de/inetsoftware/jwebassembly/module/WasmTarget.java +++ b/src/de/inetsoftware/jwebassembly/module/WasmTarget.java @@ -21,6 +21,9 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; import javax.annotation.Nonnull; @@ -33,6 +36,8 @@ public class WasmTarget implements Closeable { private OutputStream output; + private Writer sourceMap; + /** * Create a target with a file. * @@ -68,6 +73,21 @@ public OutputStream getWasmOutput() throws IOException { return output; } + /** + * Get the source map OutputStream + * + * @return the stream + * @throws IOException + * if any I/O error occur + */ + @Nonnull + public Writer getSourceMapOutput() throws IOException { + if( sourceMap == null && file != null ) { + sourceMap = new OutputStreamWriter( new BufferedOutputStream( new FileOutputStream( file + ".map" ) ), StandardCharsets.UTF_8 ); + } + return sourceMap; + } + /** * Close all streams * @@ -76,6 +96,11 @@ public OutputStream getWasmOutput() throws IOException { */ @Override public void close() throws IOException { - output.close(); + if( output != null ) { + output.close(); + } + if( sourceMap != null ) { + sourceMap.close(); + } } } diff --git a/src/de/inetsoftware/jwebassembly/sourcemap/Base64VLQ.java b/src/de/inetsoftware/jwebassembly/sourcemap/Base64VLQ.java new file mode 100644 index 00000000..1a523250 --- /dev/null +++ b/src/de/inetsoftware/jwebassembly/sourcemap/Base64VLQ.java @@ -0,0 +1,75 @@ +/* + * Copyright 2019 Volker Berlin (i-net software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.inetsoftware.jwebassembly.sourcemap; + +import java.io.IOException; + +/** + * Encode an integer value as Base64VLQ + */ +class Base64VLQ { + private static final int VLQ_BASE_SHIFT = 5; + + private static final int VLQ_BASE = 1 << VLQ_BASE_SHIFT; + + private static final int VLQ_BASE_MASK = VLQ_BASE - 1; + + private static final int VLQ_CONTINUATION_BIT = VLQ_BASE; + + private static final String BASE64_MAP = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + + /** + * no instance + */ + private Base64VLQ() { + // nothing + } + + /** + * Move the signet bit from the first position (two-complement value) to the last bit position. + * + * examples: 1 -> 2; -1 -> 3; 2 -> 4; -2 -> 5 + * + * @param value + * two-complement value + * @return converted value + */ + private static int toVLQSigned( int value ) { + return (value < 0) ? (((-value) << 1) + 1) : ((value << 1) + 0); + } + + /** + * Writes a VLQ encoded value to the provide target. + * + * @param out + * the target + * @param value + * the value + * @throws IOException + * if any I/O error occur + */ + static void appendBase64VLQ( Appendable out, int value ) throws IOException { + value = toVLQSigned( value ); + do { + int digit = value & VLQ_BASE_MASK; + value >>>= VLQ_BASE_SHIFT; + if( value > 0 ) { + digit |= VLQ_CONTINUATION_BIT; + } + out.append( BASE64_MAP.charAt( digit ) ); + } while( value > 0 ); + } +} diff --git a/src/de/inetsoftware/jwebassembly/sourcemap/SourceMapWriter.java b/src/de/inetsoftware/jwebassembly/sourcemap/SourceMapWriter.java new file mode 100644 index 00000000..ce73646e --- /dev/null +++ b/src/de/inetsoftware/jwebassembly/sourcemap/SourceMapWriter.java @@ -0,0 +1,240 @@ +/* + * Copyright 2019 Volker Berlin (i-net software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.inetsoftware.jwebassembly.sourcemap; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map.Entry; + +/** + * Generates Source Map version 3. + * + * https://sourcemaps.info/spec.html + */ +public class SourceMapWriter { + + private List mappings = new ArrayList<>(); + + private LinkedHashMap sourceFileNames = new LinkedHashMap(); + + private int nextSourceFileNameIndex; + + /** + * Adds a mapping for the given node. Mappings must be added in order. + * + * @param mapping + * the mapping + */ + public void addMapping( SourceMapping mapping ) { + if( !sourceFileNames.containsKey( mapping.getSourceFileName() ) ) { + sourceFileNames.put( mapping.getSourceFileName(), nextSourceFileNameIndex ); + nextSourceFileNameIndex++; + } + + mappings.add( mapping ); + } + + /** + * https://sourcemaps.info/spec.html + * + * @param out + * the target + * @throws IOException + * if any I/O error occur + */ + public void generate( Appendable out ) throws IOException { + out.append( "{\n" ); + appendJsonField( out, "version", "3" ); + + // the source file names + out.append( ",\n" ); + appendJsonField( out, "sources", "[" ); + appendSourceFileNames( out ); + out.append( "]" ); + + // WebAssembly does not have symbol names + out.append( ",\n" ); + appendJsonField( out, "names", "[]" ); + + // generate the mappings + out.append( ",\n" ); + appendJsonField( out, "mappings", "" ); + (new Generator( out )).appendLineMappings(); + out.append( "\n}" ); + } + + /** + * Write source file names. + * + * @param out + * the target + * @throws IOException + * if any I/O error occur + */ + private void appendSourceFileNames( Appendable out ) throws IOException { + boolean isFirst = true; + for( Entry entry : sourceFileNames.entrySet() ) { + String key = entry.getKey(); + if( isFirst ) { + isFirst = false; + } else { + out.append( ',' ); + } + appendQuoteString( out, key ); + } + } + + /** + * Write the field name to JSON source map. + * + * @param out + * the target + * @param name + * the field name + * @param value + * optional value + * @throws IOException + * if any I/O error occur + */ + private static void appendJsonField( Appendable out, String name, CharSequence value ) throws IOException { + out.append( '\"' ); + out.append( name ); + out.append( "\":" ); + out.append( value ); + } + + /** + * Write a quoted string to the JSON. + * + * @param out + * the target + * @param str + * the unquoted string + * @throws IOException + * if any I/O error occur + */ + private static void appendQuoteString( Appendable out, String str ) throws IOException { + out.append( '"' ); + for( int i = 0; i < str.length(); i++ ) { + char ch = str.charAt( i ); + switch( ch ) { + case '\\': + case '\"': + out.append( '\\' ); + break; + default: + if( ch <= 0x1f ) { + out.append( "\\u" ); + out.append( Character.forDigit( (ch >> 12) & 0xF, 16 ) ); + out.append( Character.forDigit( (ch >> 8) & 0xF, 16 ) ); + out.append( Character.forDigit( (ch >> 4) & 0xF, 16 ) ); + out.append( Character.forDigit( ch & 0xF, 16 ) ); + continue; + } + } + out.append( ch ); + } + out.append( '\"' ); + } + + /** + * The generator of the source map + */ + private class Generator { + + private final Appendable out; + + private int previousLine = -1; + + private int previousColumn; + + private int previousSourceFileNameId; + + private int previousSourceLine; + + private int previousSourceColumn; + + /** + * Create an instance. + * + * @param out + * the target for the source map + */ + Generator( Appendable out ) { + this.out = out; + } + + /** + * Append the mappings to the source map. + * + * @throws IOException + * if any I/O error occur + */ + void appendLineMappings() throws IOException { + out.append( '\"' ); + for( SourceMapping mapping : mappings ) { + int generatedLine = 1; // ever 1 for WebAssembly + int generatedColumn = mapping.getGeneratedColumn(); + + if( generatedLine > 0 && previousLine != generatedLine ) { + int start = previousLine == -1 ? 0 : previousLine; + for( int i = start; i < generatedLine; i++ ) { + out.append( ';' ); + } + } + + if( previousLine != generatedLine ) { + previousColumn = 0; + } else { + out.append( ',' ); + } + + writeEntry( mapping ); + previousLine = generatedLine; + previousColumn = generatedColumn; + } + out.append( ";\"" ); + } + + /** + * Write a single single mapping to the source map. + * + * @param mapping + * the mapping + * @throws IOException + * if any I/O error occur + */ + void writeEntry( SourceMapping mapping ) throws IOException { + int column = mapping.getGeneratedColumn(); + Base64VLQ.appendBase64VLQ( out, column - previousColumn ); + previousColumn = column; + + int sourceId = sourceFileNames.get( mapping.getSourceFileName() ); + Base64VLQ.appendBase64VLQ( out, sourceId - previousSourceFileNameId ); + previousSourceFileNameId = sourceId; + + int srcline = mapping.getSourceLine(); + int srcColumn = 0; // ever 0 for Java byte code because the line table does not support columns + Base64VLQ.appendBase64VLQ( out, srcline - previousSourceLine ); + previousSourceLine = srcline; + + Base64VLQ.appendBase64VLQ( out, srcColumn - previousSourceColumn ); + previousSourceColumn = srcColumn; + } + } +} diff --git a/src/de/inetsoftware/jwebassembly/sourcemap/SourceMapping.java b/src/de/inetsoftware/jwebassembly/sourcemap/SourceMapping.java new file mode 100644 index 00000000..dd8548ef --- /dev/null +++ b/src/de/inetsoftware/jwebassembly/sourcemap/SourceMapping.java @@ -0,0 +1,70 @@ +/* + * Copyright 2019 Volker Berlin (i-net software) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package de.inetsoftware.jwebassembly.sourcemap; + +/** + * Mapping for Source Map. + */ +public class SourceMapping { + private final int generatedColumn; + + private final int sourceLine; + + private final String sourceFileName; + + /** + * Create a mapping between a Java code line and a WebAssembly code position + * + * @param generatedColumn + * position in WebAssembly + * @param sourceLine + * Java source line + * @param sourceFileName + * Java source file + */ + public SourceMapping( int generatedColumn, int sourceLine, String sourceFileName ) { + this.generatedColumn = generatedColumn; + this.sourceLine = sourceLine; + this.sourceFileName = sourceFileName; + } + + /** + * The generated column. This is equals to the binary offset in the *.wasm file + * + * @return binary offset + */ + int getGeneratedColumn() { + return generatedColumn; + } + + /** + * The source line + * + * @return the line number + */ + int getSourceLine() { + return sourceLine; + } + + /** + * Source file name + * + * @return the name + */ + String getSourceFileName() { + return sourceFileName; + } +} diff --git a/test/de/inetsoftware/jwebassembly/sourcemap/SourceMapWriterTest.java b/test/de/inetsoftware/jwebassembly/sourcemap/SourceMapWriterTest.java new file mode 100644 index 00000000..512159cf --- /dev/null +++ b/test/de/inetsoftware/jwebassembly/sourcemap/SourceMapWriterTest.java @@ -0,0 +1,30 @@ +package de.inetsoftware.jwebassembly.sourcemap; + +import static org.junit.Assert.assertEquals; + +import java.io.IOException; + +import org.junit.Test; + +public class SourceMapWriterTest { + + @Test + public void simple() throws IOException { + SourceMapWriter map = new SourceMapWriter(); + + map.addMapping( new SourceMapping( 0, 0, "Test1.java" ) ); + map.addMapping( new SourceMapping( 5, 1, "Test1.java" ) ); + map.addMapping( new SourceMapping( 0, 4, "Test2.java" ) ); + map.addMapping( new SourceMapping( 5, 9, "Test2.java" ) ); + + StringBuilder generate = new StringBuilder(); + map.generate( generate ); + String expected = "{\n" + + "\"version\":3,\n" + + "\"sources\":[\"Test1.java\",\"Test2.java\"],\n" + + "\"names\":[],\n" + + "\"mappings\":\";AAAA,KACA,LCGA,KAKA;\"\n" + + "}"; + assertEquals( expected, generate.toString() ); + } +}