/* * Copyright 2011 The Closure Compiler Authors. * * 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 com.github.sommeri.sourcemap; import java.io.IOException; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.Map; import com.google.gson.JsonArray; import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import com.github.sommeri.sourcemap.Base64VLQ.CharIterator; import com.github.sommeri.sourcemap.Mapping.OriginalMapping; import com.github.sommeri.sourcemap.Mapping.OriginalMapping.Builder; /** * Class for parsing version 3 of the SourceMap format, as produced by the * Closure Compiler, etc. * http://code.google.com/p/closure-compiler/wiki/SourceMaps * * @author johnlenz@google.com (John Lenz) */ public class SourceMapConsumerV3 implements SourceMapConsumer, SourceMappingReversable { static final int UNMAPPED = -1; //SMS: (source map separation): added this private String file; private String sourceRoot; private String[] sources; private String[] sourcesContent; private String[] names; private int lineCount; // Slots in the lines list will be null if the line does not have any entries. private ArrayList<ArrayList<Entry>> lines = null; /** originalFile path ==> original line ==> target mappings */ private Map<String, Map<Integer, Collection<OriginalMapping>>> reverseSourceMapping; public SourceMapConsumerV3() { } static class DefaultSourceMapSupplier implements SourceMapSupplier { @Override public String getSourceMap(String url) { return null; } } /** * Parses the given contents containing a source map. */ @Override public void parse(String contents) throws SourceMapParseException { parse(contents, null); } /** * Parses the given contents containing a source map. */ public void parse(String contents, SourceMapSupplier sectionSupplier) throws SourceMapParseException { try { JsonObject sourceMapRoot = new JsonParser().parse(contents).getAsJsonObject(); parse(sourceMapRoot, sectionSupplier); } catch (JsonParseException ex) { throw new SourceMapParseException("JSON parse exception: " + ex); } } /** * Parses the given contents containing a source map. */ public void parse(JsonObject sourceMapRoot) throws SourceMapParseException { parse(sourceMapRoot, null); } /** * Parses the given contents containing a source map. */ public void parse(JsonObject sourceMapRoot, SourceMapSupplier sectionSupplier) throws SourceMapParseException { // Check basic assertions about the format. int version = sourceMapRoot.get("version").getAsInt(); if (version != 3) { throw new SourceMapParseException("Unknown version: " + version); } this.file = sourceMapRoot.get("file").getAsString(); if (file.isEmpty()) { //SMS: (source map separation): commented this - I need more tolerant parser //throw new SourceMapParseException("File entry is missing or empty "); } if (sourceMapRoot.has("sourceRoot")) this.sourceRoot = sourceMapRoot.get("sourceRoot").getAsString(); if (sourceMapRoot.has("sections")) { // Looks like a index map, try to parse it that way. parseMetaMap(sourceMapRoot, sectionSupplier); return; } lineCount = sourceMapRoot.get("lineCount").getAsInt(); String lineMap = sourceMapRoot.get("mappings").getAsString(); sources = getJavaStringArray(sourceMapRoot.get("sources").getAsJsonArray()); if (sourceMapRoot.has("sourcesContent")) { sourcesContent = getJavaStringArray(sourceMapRoot.get("sourcesContent").getAsJsonArray()); } else { sourcesContent= new String[sources.length]; } names = getJavaStringArray(sourceMapRoot.get("names").getAsJsonArray()); lines = new ArrayList<ArrayList<Entry>>(lineCount); new MappingBuilder(lineMap).build(); } /** * @param sourceMapRoot * @throws SourceMapParseException */ private void parseMetaMap(JsonObject sourceMapRoot, SourceMapSupplier sectionSupplier) throws SourceMapParseException { if (sectionSupplier == null) { sectionSupplier = new DefaultSourceMapSupplier(); } try { // Check basic assertions about the format. int version = sourceMapRoot.get("version").getAsInt(); if (version != 3) { throw new SourceMapParseException("Unknown version: " + version); } String file = sourceMapRoot.get("file").getAsString(); if (file.isEmpty()) { throw new SourceMapParseException("File entry is missing or empty"); } if (sourceMapRoot.has("lineCount") || sourceMapRoot.has("mappings") || sourceMapRoot.has("sources") || sourceMapRoot.has("names")) { throw new SourceMapParseException("Invalid map format"); } SourceMapGeneratorV3 generator = new SourceMapGeneratorV3(); JsonArray sections = sourceMapRoot.get("sections").getAsJsonArray(); for (int i = 0, count = sections.size(); i < count; i++) { JsonObject section = sections.get(i).getAsJsonObject(); if (section.has("map") && section.has("url")) { throw new SourceMapParseException("Invalid map format: section may not have both 'map' and 'url'"); } JsonObject offset = section.get("offset").getAsJsonObject(); int line = offset.get("line").getAsInt(); int column = offset.get("column").getAsInt(); String mapSectionContents; if (section.has("url")) { String url = section.get("url").getAsString(); mapSectionContents = sectionSupplier.getSourceMap(url); if (mapSectionContents == null) { throw new SourceMapParseException("Unable to retrieve: " + url); } } else if (section.has("map")) { mapSectionContents = section.get("map").getAsString(); } else { throw new SourceMapParseException("Invalid map format: section must have either 'map' or 'url'"); } generator.mergeMapSection(line, column, mapSectionContents); } StringBuilder sb = new StringBuilder(); try { generator.appendTo(sb, file); } catch (IOException e) { // Can't happen. throw new RuntimeException(e); } parse(sb.toString()); } catch (IOException ex) { throw new SourceMapParseException("IO exception: " + ex); } } @Override public OriginalMapping getMappingForLine(int lineNumber, int column) { // Normalize the line and column numbers to 0. lineNumber--; column--; if (lineNumber < 0 || lineNumber >= lines.size()) { return null; } Preconditions.checkState(lineNumber >= 0); Preconditions.checkState(column >= 0); // If the line is empty return the previous mapping. if (lines.get(lineNumber) == null) { return getPreviousMapping(lineNumber); } ArrayList<Entry> entries = lines.get(lineNumber); // No empty lists. Preconditions.checkState(entries.size() > 0); if (entries.get(0).getGeneratedColumn() > column) { return getPreviousMapping(lineNumber); } int index = search(entries, column, 0, entries.size() - 1); Preconditions.checkState(index >= 0, "unexpected: " + index); return getOriginalMappingForEntry(entries.get(index)); } @Override public Collection<String> getOriginalSources() { return Arrays.asList(sources); } public Collection<String> getOriginalSourcesContent() { return Arrays.asList(sourcesContent); } public String getFile() { return file; } public String getSourceRoot() { return sourceRoot; } @Override public Collection<OriginalMapping> getReverseMapping(String originalFile, int line, int column) { // TODO(user): This implementation currently does not make use of the column // parameter. // Synchronization needs to be handled by callers. if (reverseSourceMapping == null) { createReverseMapping(); } Map<Integer, Collection<OriginalMapping>> sourceLineToCollectionMap = reverseSourceMapping.get(originalFile); if (sourceLineToCollectionMap == null) { return Collections.emptyList(); } else { Collection<OriginalMapping> mappings = sourceLineToCollectionMap.get(line); if (mappings == null) { return Collections.emptyList(); } else { return mappings; } } } private String[] getJavaStringArray(JsonArray array) { int len = array.size(); String[] result = new String[len]; for (int i = 0; i < len; i++) { result[i] = array.get(i).isJsonNull()? null : array.get(i).getAsString(); } return result; } private class MappingBuilder { private static final int MAX_ENTRY_VALUES = 5; private final StringCharIterator content; private int line = 0; private int previousCol = 0; private int previousSrcId = 0; private int previousSrcLine = 0; private int previousSrcColumn = 0; private int previousNameId = 0; MappingBuilder(String lineMap) { this.content = new StringCharIterator(lineMap); } void build() { int[] temp = new int[MAX_ENTRY_VALUES]; ArrayList<Entry> entries = new ArrayList<Entry>(); while (content.hasNext()) { // ';' denotes a new line. if (tryConsumeToken(';')) { // The line is complete, store the result for the line, // null if the line is empty. ArrayList<Entry> result; if (entries.size() > 0) { result = entries; // A new array list for the next line. entries = new ArrayList<Entry>(); } else { result = null; } lines.add(result); entries.clear(); line++; previousCol = 0; } else { // grab the next entry for the current line. int entryValues = 0; while (!entryComplete()) { temp[entryValues] = nextValue(); entryValues++; } Entry entry = decodeEntry(temp, entryValues); validateEntry(entry); entries.add(entry); // Consume the separating token, if there is one. tryConsumeToken(','); } } } /** * Sanity check the entry. */ private void validateEntry(Entry entry) { Preconditions.checkState(line < lineCount); Preconditions.checkState(entry.getSourceFileId() == UNMAPPED || entry.getSourceFileId() < sources.length); Preconditions.checkState(entry.getSourceFileId() == UNMAPPED || entry.getSourceFileId() < sourcesContent.length); Preconditions.checkState(entry.getNameId() == UNMAPPED || entry.getNameId() < names.length); } /** * Decodes the next entry, using the previous encountered values to decode * the relative values. * * @param vals * An array of integers that represent values in the entry. * @param entryValues * The number of entries in the array. * @return The entry object. */ private Entry decodeEntry(int[] vals, int entryValues) { Entry entry; switch (entryValues) { // The first values, if present are in the following order: // 0: the starting column in the current line of the generated file // 1: the id of the original source file // 2: the starting line in the original source // 3: the starting column in the original source // 4: the id of the original symbol name // The values are relative to the last encountered value for that field. // Note: the previously column value for the generated file is reset // to '0' when a new line is encountered. This is done in the 'build' // method. case 1: // An unmapped section of the generated file. entry = new UnmappedEntry(vals[0] + previousCol); // Set the values see for the next entry. previousCol = entry.getGeneratedColumn(); return entry; case 4: // A mapped section of the generated file. entry = new UnnamedEntry(vals[0] + previousCol, vals[1] + previousSrcId, vals[2] + previousSrcLine, vals[3] + previousSrcColumn); // Set the values see for the next entry. previousCol = entry.getGeneratedColumn(); previousSrcId = entry.getSourceFileId(); previousSrcLine = entry.getSourceLine(); previousSrcColumn = entry.getSourceColumn(); return entry; case 5: // A mapped section of the generated file, that has an associated // name. entry = new NamedEntry(vals[0] + previousCol, vals[1] + previousSrcId, vals[2] + previousSrcLine, vals[3] + previousSrcColumn, vals[4] + previousNameId); // Set the values see for the next entry. previousCol = entry.getGeneratedColumn(); previousSrcId = entry.getSourceFileId(); previousSrcLine = entry.getSourceLine(); previousSrcColumn = entry.getSourceColumn(); previousNameId = entry.getNameId(); return entry; default: throw new IllegalStateException("Unexpected number of values for entry:" + entryValues); } } private boolean tryConsumeToken(char token) { if (content.hasNext() && content.peek() == token) { // consume the comma content.next(); return true; } return false; } private boolean entryComplete() { if (!content.hasNext()) { return true; } char c = content.peek(); return (c == ';' || c == ','); } private int nextValue() { return Base64VLQ.decode(content); } } /** * Perform a binary search on the array to find a section that covers the * target column. */ private int search(ArrayList<Entry> entries, int target, int start, int end) { while (true) { int mid = ((end - start) / 2) + start; int compare = compareEntry(entries, mid, target); if (compare == 0) { return mid; } else if (compare < 0) { // it is in the upper half start = mid + 1; if (start > end) { return end; } } else { // it is in the lower half end = mid - 1; if (end < start) { return end; } } } } /** * Compare an array entry's column value to the target column value. */ private int compareEntry(ArrayList<Entry> entries, int entry, int target) { return entries.get(entry).getGeneratedColumn() - target; } /** * Returns the mapping entry that proceeds the supplied line or null if no * such entry exists. */ private OriginalMapping getPreviousMapping(int lineNumber) { do { if (lineNumber == 0) { return null; } lineNumber--; } while (lines.get(lineNumber) == null); ArrayList<Entry> entries = lines.get(lineNumber); return getOriginalMappingForEntry(entries.get(entries.size() - 1)); } /** * Creates an "OriginalMapping" object for the given entry object. */ private OriginalMapping getOriginalMappingForEntry(Entry entry) { if (entry.getSourceFileId() == UNMAPPED) { return null; } else { // Adjust the line/column here to be start at 1. Builder x = OriginalMapping.newBuilder().setOriginalFile(sources[entry.getSourceFileId()]).setLineNumber(entry.getSourceLine() + 1).setColumnPosition(entry.getSourceColumn() + 1); if (entry.getNameId() != UNMAPPED) { x.setIdentifier(names[entry.getNameId()]); } return x.build(); } } /** * Reverse the source map; the created mapping will allow us to quickly go * from a source file and line number to a collection of target * OriginalMappings. */ private void createReverseMapping() { reverseSourceMapping = new HashMap<String, Map<Integer, Collection<OriginalMapping>>>(); for (int targetLine = 0; targetLine < lines.size(); targetLine++) { ArrayList<Entry> entries = lines.get(targetLine); if (entries != null) { for (Entry entry : entries) { if (entry.getSourceFileId() != UNMAPPED && entry.getSourceLine() != UNMAPPED) { String originalFile = sources[entry.getSourceFileId()]; if (!reverseSourceMapping.containsKey(originalFile)) { reverseSourceMapping.put(originalFile, new HashMap<Integer, Collection<OriginalMapping>>()); } Map<Integer, Collection<OriginalMapping>> lineToCollectionMap = reverseSourceMapping.get(originalFile); int sourceLine = entry.getSourceLine(); if (!lineToCollectionMap.containsKey(sourceLine)) { lineToCollectionMap.put(sourceLine, new ArrayList<OriginalMapping>(1)); } Collection<OriginalMapping> mappings = lineToCollectionMap.get(sourceLine); Builder builder = OriginalMapping.newBuilder().setLineNumber(targetLine).setColumnPosition(entry.getGeneratedColumn()); mappings.add(builder.build()); } } } } } /** * A implementation of the Base64VLQ CharIterator used for decoding the * mappings encoded in the JSON string. */ private static class StringCharIterator implements CharIterator { final String content; final int length; int current = 0; StringCharIterator(String content) { this.content = content; this.length = content.length(); } @Override public char next() { return content.charAt(current++); } char peek() { return content.charAt(current); } @Override public boolean hasNext() { return current < length; } } /** * Represents a mapping entry in the source map. */ private interface Entry { int getGeneratedColumn(); int getSourceFileId(); int getSourceLine(); int getSourceColumn(); int getNameId(); } /** * This class represents a portion of the generated file, that is not mapped * to a section in the original source. */ private static class UnmappedEntry implements Entry { private final int column; UnmappedEntry(int column) { this.column = column; } @Override public int getGeneratedColumn() { return column; } @Override public int getSourceFileId() { return UNMAPPED; } @Override public int getSourceLine() { return UNMAPPED; } @Override public int getSourceColumn() { return UNMAPPED; } @Override public int getNameId() { return UNMAPPED; } } /** * This class represents a portion of the generated file, that is mapped to a * section in the original source. */ private static class UnnamedEntry extends UnmappedEntry { private final int srcFile; private final int srcLine; private final int srcColumn; UnnamedEntry(int column, int srcFile, int srcLine, int srcColumn) { super(column); this.srcFile = srcFile; this.srcLine = srcLine; this.srcColumn = srcColumn; } @Override public int getSourceFileId() { return srcFile; } @Override public int getSourceLine() { return srcLine; } @Override public int getSourceColumn() { return srcColumn; } @Override public int getNameId() { return UNMAPPED; } } /** * This class represents a portion of the generated file, that is mapped to a * section in the original source, and is associated with a name. */ private static class NamedEntry extends UnnamedEntry { private final int name; NamedEntry(int column, int srcFile, int srcLine, int srcColumn, int name) { super(column, srcFile, srcLine, srcColumn); this.name = name; } @Override public int getNameId() { return name; } } public static interface EntryVisitor { void visit(String sourceName, String sourceContent, String symbolName, FilePosition sourceStartPosition, FilePosition startPosition, FilePosition endPosition); } public void visitMappings(EntryVisitor visitor) { boolean pending = false; String sourceName = null; String sourceContent = null; String symbolName = null; FilePosition sourceStartPosition = null; FilePosition startPosition = null; final int lineCount = lines.size(); for (int i = 0; i < lineCount; i++) { ArrayList<Entry> line = lines.get(i); if (line != null) { final int entryCount = line.size(); for (int j = 0; j < entryCount; j++) { Entry entry = line.get(j); if (pending) { FilePosition endPosition = new FilePosition(i, entry.getGeneratedColumn()); visitor.visit(sourceName, sourceContent, symbolName, sourceStartPosition, startPosition, endPosition); pending = false; } if (entry.getSourceFileId() != UNMAPPED) { pending = true; sourceName = sources[entry.getSourceFileId()]; sourceContent = sourcesContent[entry.getSourceFileId()]; symbolName = (entry.getNameId() != UNMAPPED) ? names[entry.getNameId()] : null; sourceStartPosition = new FilePosition(entry.getSourceLine(), entry.getSourceColumn()); startPosition = new FilePosition(i, entry.getGeneratedColumn()); } } } } //TODO: (closure report) (source map separation) I added this to because last mapping was never visited if (pending) { FilePosition endPosition = new FilePosition(startPosition.getLine(), startPosition.getColumn()); visitor.visit(sourceName, sourceContent, symbolName, sourceStartPosition, startPosition, endPosition); } //TODO: source map (closure report) - investigate and maybe fill bug to closure - they generate additional mappings to mark ends which is weird. } }