sourcemap.nim 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207
  1. import std/[strutils, strscans, parseutils, assertions]
  2. type
  3. Segment = object
  4. ## Segment refers to a block of something in the JS output.
  5. ## This could be a token or an entire line
  6. original: int # Column in the Nim source
  7. generated: int # Column in the generated JS
  8. name: int # Index into names list (-1 for no name)
  9. Mapping = object
  10. ## Mapping refers to a line in the JS output.
  11. ## It is made up of segments which refer to the tokens in the line
  12. case inSource: bool # Whether the line in JS has Nim equivalent
  13. of true:
  14. file: int # Index into files list
  15. line: int # 0 indexed line of code in the Nim source
  16. segments: seq[Segment]
  17. else: discard
  18. SourceInfo = object
  19. mappings: seq[Mapping]
  20. names, files: seq[string]
  21. SourceMap* = object
  22. version*: int
  23. sources*: seq[string]
  24. names*: seq[string]
  25. mappings*: string
  26. file*: string
  27. func addSegment(info: var SourceInfo, original, generated: int, name: string = "") {.raises: [].} =
  28. ## Adds a new segment into the current line
  29. assert info.mappings.len > 0, "No lines have been added yet"
  30. var segment = Segment(original: original, generated: generated, name: -1)
  31. if name != "":
  32. # Make name be index into names list
  33. segment.name = info.names.find(name)
  34. if segment.name == -1:
  35. segment.name = info.names.len
  36. info.names &= name
  37. assert info.mappings[^1].inSource, "Current line isn't in Nim source"
  38. info.mappings[^1].segments &= segment
  39. func newLine(info: var SourceInfo) {.raises: [].} =
  40. ## Add new mapping which doesn't appear in the Nim source
  41. info.mappings &= Mapping(inSource: false)
  42. func newLine(info: var SourceInfo, file: string, line: int) {.raises: [].} =
  43. ## Starts a new line in the mappings. Call addSegment after this to add
  44. ## segments into the line
  45. var mapping = Mapping(inSource: true, line: line)
  46. # Set file to file position. Add in if needed
  47. mapping.file = info.files.find(file)
  48. if mapping.file == -1:
  49. mapping.file = info.files.len
  50. info.files &= file
  51. info.mappings &= mapping
  52. # base64_VLQ
  53. func encode*(values: seq[int]): string {.raises: [].} =
  54. ## Encodes a series of integers into a VLQ base64 encoded string
  55. # References:
  56. # - https://www.lucidchart.com/techblog/2019/08/22/decode-encoding-base64-vlqs-source-maps/
  57. # - https://github.com/rails/sprockets/blob/main/guides/source_maps.md#source-map-file
  58. const
  59. alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
  60. shift = 5
  61. continueBit = 1 shl 5
  62. mask = continueBit - 1
  63. result = ""
  64. for val in values:
  65. # Sign is stored in first bit
  66. var newVal = abs(val) shl 1
  67. if val < 0:
  68. newVal = newVal or 1
  69. # Now comes the variable length part
  70. # This is how we are able to store large numbers
  71. while true:
  72. # We only encode 5 bits.
  73. var masked = newVal and mask
  74. newVal = newVal shr shift
  75. # If there is still something left
  76. # then signify with the continue bit that the
  77. # decoder should keep decoding
  78. if newVal > 0:
  79. masked = masked or continueBit
  80. result &= alphabet[masked]
  81. # If the value is zero then we have nothing left to encode
  82. if newVal == 0:
  83. break
  84. iterator tokenize*(line: string): (int, string) =
  85. ## Goes through a line and splits it into Nim identifiers and
  86. ## normal JS code. This allows us to map mangled names back to Nim names.
  87. ## Yields (column, name). Doesn't yield anything but identifiers.
  88. ## See mangleName in compiler/jsgen.nim for how name mangling is done
  89. var
  90. col = 0
  91. token = ""
  92. while col < line.len:
  93. var
  94. token: string = ""
  95. name: string = ""
  96. # First we find the next identifier
  97. col += line.skipWhitespace(col)
  98. col += line.skipUntil(IdentStartChars, col)
  99. let identStart = col
  100. col += line.parseIdent(token, col)
  101. # Idents will either be originalName_randomInt or HEXhexCode_randomInt
  102. if token.startsWith("HEX"):
  103. var hex: int = 0
  104. # 3 = "HEX".len and we only want to parse the two integers after it
  105. discard token[3 ..< 5].parseHex(hex)
  106. name = $chr(hex)
  107. elif not token.endsWith("_Idx"): # Ignore address indexes
  108. # It might be in the form originalName_randomInt
  109. let lastUnderscore = token.rfind('_')
  110. if lastUnderscore != -1:
  111. name = token[0..<lastUnderscore]
  112. if name != "":
  113. yield (identStart, name)
  114. func parse*(source: string): SourceInfo =
  115. ## Parses the JS output for embedded line info
  116. ## So it can convert those into a series of mappings
  117. result = default(SourceInfo)
  118. var
  119. skipFirstLine = true
  120. currColumn = 0
  121. currLine = 0
  122. currFile = ""
  123. # Add each line as a node into the output
  124. for line in source.splitLines():
  125. var
  126. lineNumber: int = 0
  127. linePath: string = ""
  128. column: int = 0
  129. if line.strip().scanf("/* line $i:$i \"$+\" */", lineNumber, column, linePath):
  130. # When we reach the first line mappinsegmentg then we can assume
  131. # we can map the rest of the JS lines to Nim lines
  132. currColumn = column # Column is already zero indexed
  133. currLine = lineNumber - 1
  134. currFile = linePath
  135. # Lines are zero indexed
  136. result.newLine(currFile, currLine)
  137. # Skip whitespace to find the starting column
  138. result.addSegment(currColumn, line.skipWhitespace())
  139. elif currFile != "":
  140. result.newLine(currFile, currLine)
  141. # There mightn't be any tokens so add a starting segment
  142. result.addSegment(currColumn, line.skipWhitespace())
  143. for jsColumn, token in line.tokenize:
  144. result.addSegment(currColumn, jsColumn, token)
  145. else:
  146. result.newLine()
  147. func toSourceMap*(info: SourceInfo, file: string): SourceMap {.raises: [].} =
  148. ## Convert from high level SourceInfo into the required SourceMap object
  149. # Add basic info
  150. result = SourceMap(version: 3, file: file, sources: info.files, names: info.names)
  151. # Convert nodes into mappings.
  152. # Mappings are split into blocks where each block referes to a line in the outputted JS.
  153. # Blocks can be separated into statements which refere to tokens on the line.
  154. # Since the mappings depend on previous values we need to
  155. # keep track of previous file, name, etc
  156. var
  157. prevFile = 0
  158. prevLine = 0
  159. prevName = 0
  160. prevNimCol = 0
  161. for mapping in info.mappings:
  162. # We know need to encode segments with the following fields
  163. # All these fields are relative to their previous values
  164. # - 0: Column in generated code
  165. # - 1: Index of Nim file in source list
  166. # - 2: Line in Nim source
  167. # - 3: Column in Nim source
  168. # - 4: Index in names list
  169. if mapping.inSource:
  170. # JS Column is special in that it is reset after every line
  171. var prevJSCol = 0
  172. for segment in mapping.segments:
  173. var values = @[segment.generated - prevJSCol, mapping.file - prevFile, mapping.line - prevLine, segment.original - prevNimCol]
  174. # Add name field if needed
  175. if segment.name != -1:
  176. values &= segment.name - prevName
  177. prevName = segment.name
  178. prevJSCol = segment.generated
  179. prevNimCol = segment.original
  180. prevFile = mapping.file
  181. prevLine = mapping.line
  182. result.mappings &= encode(values) & ","
  183. # Remove trailing ,
  184. if mapping.segments.len > 0:
  185. result.mappings.setLen(result.mappings.len - 1)
  186. result.mappings &= ";"
  187. proc genSourceMap*(source: string, outFile: string): SourceMap =
  188. let node = parse(source)
  189. result = node.toSourceMap(outFile)