Skip to content

Commit

Permalink
Implement support for exports in package.json. Fixes #367 (#474)
Browse files Browse the repository at this point in the history
- handle glob expansion
  • Loading branch information
oyvindberg authored Oct 10, 2022
1 parent d9a36fa commit 488fbf8
Show file tree
Hide file tree
Showing 8 changed files with 256 additions and 9 deletions.
5 changes: 2 additions & 3 deletions .run/CI Demoset.run.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<option name="ALTERNATIVE_JRE_PATH" value="/usr/lib/jvm/java-8-openjdk-amd64/jre" />
<option name="MAIN_CLASS_NAME" value="org.scalablytyped.converter.Main" />
<module name="importer" />
<option name="PROGRAM_PARAMETERS" value="-softWrites -nextVersions2 -enableParseCache -offline -dontCleanProject -forceCommit -demoSet" />
<option name="PROGRAM_PARAMETERS" value="-softWrites -nextVersions2 -enableParseCache -offline -dontCleanProject -forceCommit -demoSet2 semantic-ui-react" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="org.scalablytyped.converter.*" />
Expand All @@ -12,7 +12,6 @@
</extension>
<method v="2">
<option name="Make" enabled="true" />
<option name="BSP.BeforeRunTask" enabled="true" />
</method>
</configuration>
</component>
</component>
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,11 @@ class LibraryResolver(
private val byName: Map[TsIdentLibrary, LibTsSource] =
allSources.groupBy(_.libName).mapValues(_.head).updated(TsIdent.std, stdLib)

def module(current: LibTsSource, folder: InFolder, value: String): Option[ResolvedModule] =
def module(source: LibTsSource, folder: InFolder, value: String): Option[ResolvedModule] =
value match {
case LocalPath(localPath) =>
file(folder, localPath).map { inFile =>
ResolvedModule.Local(inFile, LibraryResolver.moduleNameFor(current, inFile).head)
ResolvedModule.Local(inFile, LibraryResolver.moduleNameFor(source, inFile).head)
}
case globalRef =>
val modName = ModuleNameParser(globalRef.split("/").to[List], keepIndexFragment = true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,26 @@ class Phase1ReadTypescript(
val flattened = FlattenTrees(preparedFiles.map(_._1))
val depsFromFiles = preparedFiles.foldLeft(Set.empty[LibTsSource]) { case (acc, (_, deps)) => acc ++ deps }

val withExportedModules = source.packageJsonOpt.flatMap(_.parsedExported).foldLeft(flattened) {
case (file, exports) =>
val proxyModules = ProxyModule.fromExports(
source,
logger,
resolve,
existing = file.membersByName.contains,
exports,
)
file.copy(members = IArray.fromTraversable(proxyModules).map(_.asModule) ++ file.members)
}

val withFilteredModules: TsParsedFile =
if (ignoredModulePrefixes.nonEmpty)
flattened.copy(members = flattened.members.filterNot {
withExportedModules.copy(members = withExportedModules.members.filterNot {
case x: TsDeclModule => ignoreModule(x.name)
case x: TsAugmentedModule => ignoreModule(x.name)
case _ => false
})
else flattened
else withExportedModules

val stdlibSourceOpt: Option[LibTsSource] =
if (includedFiles.exists(_.path === resolve.stdLib.path)) None else Option(resolve.stdLib)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package org.scalablytyped.converter.internal
package importer

import com.olvind.logging.Logger
import org.scalablytyped.converter.internal.ts._

case class ProxyModule(
comments: Comments,
libName: TsIdentLibrary,
fromModule: TsIdentModule,
toModule: TsIdentModule,
) {
val asModule = TsDeclModule(
comments = comments,
declared = false,
name = toModule,
members = IArray(
TsExport(
comments = NoComments,
typeOnly = false,
tpe = ExportType.Named,
exported = TsExportee.Star(None, fromModule),
),
),
codePath = CodePath.HasPath(libName, TsQIdent(IArray(toModule))),
jsLocation = JsLocation.Zero,
)
}

object ProxyModule {
val FromExports = Comments("/* from `exports` in `package.json` */\n")

def fromExports(
source: LibTsSource,
logger: Logger[_],
resolve: LibraryResolver,
existing: TsIdent => Boolean,
exports: Map[String, String],
): Iterable[ProxyModule] = {
val expandedGlobs = exports.flatMap {
case tuple @ (exportedName, exportedTypesRelPath) =>
exportedTypesRelPath.split('*') match {
case Array(_) => Some(tuple)
case Array(pre, post) =>
val splitPrePath = pre.split('/').filterNot(_ == ".")

// last part of `pre` may not be a full path fragment, so drop it and consider it below
val (folderPrePart, preFileNameStart) =
if (pre.endsWith("/")) (splitPrePath, "")
else (splitPrePath.dropRight(1), splitPrePath.lastOption.getOrElse(""))

val lookIn = folderPrePart.foldLeft(source.folder.path)(_ / _)

// need to take whatever the glob expanded to and expand it into both `name` and `types`
val expandedFragments = os.walk(lookIn).flatMap { path =>
val relPathString = path.relativeTo(lookIn).toString()

if (relPathString.startsWith(preFileNameStart) && relPathString.endsWith(post))
Some(relPathString.drop(preFileNameStart.length).dropRight(post.length))
else None
}

val expanded =
expandedFragments.map(m => (exportedName.replace("*", m), exportedTypesRelPath.replace("*", m)))
expanded

case _ => logger.fatal(s"need to add support for more than one '*' in glob pattern $exportedTypesRelPath")
}
}

val libModule = TsIdentModule.fromLibrary(source.libName)

expandedGlobs.flatMap {
case tuple @ (name, types) =>
val fromModule = resolve.module(source, source.folder, types) match {
case Some(resolvedModule) => resolvedModule.moduleName
case None => logger.fatal(s"couldn't resolve $tuple")
}

val toModule =
libModule.copy(fragments = libModule.fragments ++ name.split("/").toList.filterNot(_ == "."))

if (existing(toModule)) None
else {
logger.info(s"exposing module ${toModule.value} from ${fromModule.value}")
Some(ProxyModule(FromExports, source.libName, fromModule, toModule))
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package org.scalablytyped.converter.internal.importer

import org.scalablytyped.converter.internal.Json
import org.scalablytyped.converter.internal.ts.PackageJson
import org.scalatest.funsuite.AnyFunSuite

class ExportsJsonTest extends AnyFunSuite {
test("no - 1") {
val content =
"""{"exports": [
| {
| "default": "./index.js"
| },
| "./index.js"
| ]
|}""".stripMargin
val actual = Json.force[PackageJson](content).parsedExported

assert(actual === None)
}

test("no - 2") {
val content =
"""{"exports": {
| "import": "./build/lib/index.js",
| "require": "./build/index.cjs"
| }
|}""".stripMargin
val actual = Json.force[PackageJson](content).parsedExported

assert(actual === None)
}
test("no - 3") {
val content =
"""{"exports": {
| ".": [
| {
| "import": "./build/lib/index.js",
| "require": "./build/index.cjs"
| },
| "./build/index.cjs"
| ]
| }
|}""".stripMargin
val actual = Json.force[PackageJson](content).parsedExported

assert(actual === None)
}
test("no - 4") {
val content =
"""{"exports": {
| ".": [
| {
| "import": "./build/lib/index.js",
| "require": "./build/index.cjs"
| },
| "./build/index.cjs"
| ],
| "./browser": [
| "./browser.js"
| ]
| }}""".stripMargin
val actual = Json.force[PackageJson](content).parsedExported

assert(actual === None)
}

test("no - 5") {
val content =
"""{"exports": "picocolors.js"}""".stripMargin
val actual = Json.force[PackageJson](content).parsedExported

assert(actual === None)
}

test("yes - 5") {
val content =
"""{"exports": {
| "./analytics": {
| "types": "./analytics/dist/analytics/index.d.ts",
| "node": {
| "require": "./analytics/dist/index.cjs.js",
| "import": "./analytics/dist/index.mjs"
| },
| "default": "./analytics/dist/index.esm.js"
| },
| "./app": {
| "types": "./app/dist/app/index.d.ts",
| "node": {
| "require": "./app/dist/index.cjs.js",
| "import": "./app/dist/index.mjs"
| },
| "default": "./app/dist/index.esm.js"
| },
| "./package.json": "./package.json"
| }}""".stripMargin
val actual = Json.force[PackageJson](content).parsedExported
val expected = Some(
Map(
"./analytics" -> "./analytics/dist/analytics/index.d.ts",
"./app" -> "./app/dist/app/index.d.ts",
),
)
assert(actual === expected)
}
}
2 changes: 1 addition & 1 deletion tests/pixi.js/check-3/p/pixi_dot_js/build.sbt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
organization := "org.scalablytyped"
name := "pixi_dot_js"
version := "0.0-unknown-ba15ed"
version := "0.0-unknown-ba7eae"
scalaVersion := "3.1.2"
enablePlugins(ScalaJSPlugin)
libraryDependencies ++= Seq(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,27 @@ object mod {
extends typings.pixiUtils.mod.EventEmitter[EventTypes]
object EventEmitter extends Shortcut {

/* This class was inferred from a value with a constructor. In rare cases (like HTMLElement in the DOM) it might not work as you expect. */
@JSImport("pixi.js", "utils.EventEmitter")
@js.native
open class ^[EventTypes] ()
extends StObject
with typings.eventemitter3.mod.EventEmitter[EventTypes]

@JSImport("pixi.js", "utils.EventEmitter")
@js.native
val ^ : js.Object & EventEmitterStatic = js.native
@JSImport("pixi.js", "utils.EventEmitter.EventEmitter")
@js.native
val EventEmitter: EventEmitterStatic = js.native

/* This class was inferred from a value with a constructor, it was renamed because a distinct type already exists with the same name. */
@JSImport("pixi.js", "utils.EventEmitter.EventEmitter")
@js.native
open class EventEmitterCls[EventTypes] ()
extends StObject
with typings.eventemitter3.mod.EventEmitter[EventTypes]

type _To = js.Object & EventEmitterStatic

/* This means you don't have to write `^`, but can instead just say `EventEmitter.foo` */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ case class PackageJson(
types: Option[Json],
files: Option[IArray[String]],
dist: Option[PackageJson.Dist],
exports: Option[Json],
) {
def allLibs(dev: Boolean, peer: Boolean): SortedMap[TsIdentLibrary, String] =
smash(IArray.fromOptions(dependencies, devDependencies.filter(_ => dev), peerDependencies.filter(_ => peer))).toSorted
Expand Down Expand Up @@ -91,6 +92,31 @@ case class PackageJson(

module.map(look).filter(_.nonEmpty)
}

// this is an impossibly flexibly defined structure, so we're maximally flexible in this parse step
// we only extract the `types` information for now
def parsedExported: Option[Map[String, String]] = {
def look(json: Json): Map[String, String] =
json.fold[Map[String, String]](
Map.empty,
_ => Map.empty,
_ => Map.empty,
_ => Map.empty,
values => maps.smash(IArray.fromTraversable(values.map(look))),
obj =>
obj.toMap.flatMap {
case (name, value) =>
val maybe = for {
obj <- value.asObject
types <- obj.toMap.get("types")
typesString <- types.asString
} yield typesString
maybe.map(tpe => (name, tpe))
},
)

exports.map(look).filter(_.nonEmpty)
}
}

object PackageJson {
Expand All @@ -101,7 +127,7 @@ object PackageJson {
implicit val decodesDist: Decoder[Dist] = deriveDecoder
}

val Empty: PackageJson = PackageJson(None, None, None, None, None, None, None, None, None)
val Empty: PackageJson = PackageJson(None, None, None, None, None, None, None, None, None, None)

implicit val encodes: Encoder[PackageJson] = deriveEncoder
implicit val decodes: Decoder[PackageJson] = deriveDecoder
Expand Down

0 comments on commit 488fbf8

Please sign in to comment.