From 369a4c5259f196fee2dc496f76d644228a84d427 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Mon, 23 Oct 2023 21:26:31 +0100 Subject: [PATCH 1/3] Refactor command lines --- build.sbt | 1 + .../com/nawforce/apexlink/ApexLink.scala | 23 -- .../scala/com/nawforce/apexlink/api/Org.scala | 57 +++- .../com/nawforce/apexlink/cmds/Check.scala | 259 ----------------- .../com/nawforce/apexlink/cmds/Server.scala | 31 -- .../com/nawforce/apexlink/rpc/OrgAPI.scala | 18 +- .../nawforce/apexlink/rpc/OrgAPIImpl.scala | 29 +- .../github/apexdevtools/apexls/Indexer.scala | 13 - .../io/github/apexdevtools/apexls/Main.scala | 272 +++++++++++++++++- .../apexdevtools/apexls/PMDReport.scala | 124 ++++---- .../github/apexdevtools/apexls/Server.scala | 14 +- 11 files changed, 418 insertions(+), 423 deletions(-) delete mode 100644 jvm/src/main/scala/com/nawforce/apexlink/ApexLink.scala delete mode 100644 jvm/src/main/scala/com/nawforce/apexlink/cmds/Check.scala delete mode 100644 jvm/src/main/scala/com/nawforce/apexlink/cmds/Server.scala delete mode 100644 jvm/src/main/scala/io/github/apexdevtools/apexls/Indexer.scala diff --git a/build.sbt b/build.sbt index 25b2444b0..4832ba273 100644 --- a/build.sbt +++ b/build.sbt @@ -46,6 +46,7 @@ lazy val apexls = crossProject(JSPlatform, JVMPlatform) "com.github.nawforce" %%% "scala-json-rpc" % "1.1.0", "com.github.nawforce" %%% "scala-json-rpc-upickle-json-serializer" % "1.1.0", "com.lihaoyi" %%% "upickle" % "1.2.0", + "com.lihaoyi" %%% "mainargs" % "0.5.4", "org.scalatest" %%% "scalatest" % "3.2.0" % Test ) ) diff --git a/jvm/src/main/scala/com/nawforce/apexlink/ApexLink.scala b/jvm/src/main/scala/com/nawforce/apexlink/ApexLink.scala deleted file mode 100644 index 10735d3b5..000000000 --- a/jvm/src/main/scala/com/nawforce/apexlink/ApexLink.scala +++ /dev/null @@ -1,23 +0,0 @@ -/* - Copyright (c) 2019 Kevin Jones, All rights reserved. - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions - are met: - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - 3. The name of the author may not be used to endorse or promote products - derived from this software without specific prior written permission. - */ - -package com.nawforce.apexlink - -import com.nawforce.apexlink.cmds.Check - -object ApexLink { - def main(args: Array[String]): Unit = { - System.exit(Check.run(args)) - } -} diff --git a/jvm/src/main/scala/com/nawforce/apexlink/api/Org.scala b/jvm/src/main/scala/com/nawforce/apexlink/api/Org.scala index fc8f6853e..0efb16409 100644 --- a/jvm/src/main/scala/com/nawforce/apexlink/api/Org.scala +++ b/jvm/src/main/scala/com/nawforce/apexlink/api/Org.scala @@ -15,6 +15,7 @@ package com.nawforce.apexlink.api import com.nawforce.apexlink.org.OPM +import com.nawforce.apexlink.plugins.{PluginsManager, UnusedPlugin} import com.nawforce.apexlink.rpc.{ BombScore, ClassTestItem, @@ -23,6 +24,7 @@ import com.nawforce.apexlink.rpc.{ HoverItem, LocationLink, MethodTestItem, + OpenOptions, Rename, TargetLocation } @@ -31,7 +33,7 @@ import com.nawforce.pkgforce.diagnostics.{IssuesManager, LoggerOps} import com.nawforce.pkgforce.names.TypeIdentifier import com.nawforce.pkgforce.path.{PathLike, PathLocation} import com.nawforce.pkgforce.workspace.{ProjectConfig, Workspace} -import com.nawforce.runtime.platform.Path +import com.nawforce.runtime.platform.{Environment, Path} /** A virtual Org used to present the analysis functionality in a familiar way. * @@ -39,19 +41,13 @@ import com.nawforce.runtime.platform.Path * same time but most use cases just need one creating, see Org.newOrg(). The Org functions as a * container of multiple [[Package]] objects and maintains a set of discovered issues from the * analysis of the package metadata. All orgs have at least one 'unmanaged' package identifiable by - * having no namespace. + * having no namespace. At any point you can list of current issues with the packages from + * getIssues. When you create an Org the metadata from the provided workspace directory will be + * loaded automatically, honouring the settings in sfdx-project.json & .forceignore files if present. * - * In the simple case after creating an Org, you should add one or more packages detailing where - * package metadata is stored. Adding large packages can take considerable CPU and memory - * resources. Once the packages are loaded the metadata within them can be mutated using the - * [[Package]] methods. At any point you can list of current issues with the packages from - * getIssues. - * - * When metadata changes are requested (see [[Package.refresh]] they are queued for later - * processing either via calling [[Org.flush]] or via automatic flushing (the default). Flushing - * also updates a disk cache that helps significantly reduce initial loading times. The flushing - * model used by an [[Org]] is set on construction, see [[ServerOps.setAutoFlush]] to change to - * manual flushing. + * Changes made to the workspace metadata files are automatically handled, although there can be some + * lag in the file watching. You can also prompt for changes to be handled via [[Package.refresh]] + * to have better control over handling. * * Orgs and Packages are not thread safe, serialise all calls to them. */ @@ -250,13 +246,44 @@ trait Org { object Org { - /** Create a new empty Org to which you can add packages for code analysis. */ + /** Create a new virtual org for a workspace + * @param path workspace directory + */ def newOrg(path: String): Org = { newOrg(Path(path)) } - /** Create a new empty Org to which you can add packages for code analysis. */ + /** Create a new virtual org for a workspace + * @param path workspace directory + */ def newOrg(path: PathLike): Org = { + newOrg(path, OpenOptions.default()) + } + + /** Create a new virtual org for a workspace + * @param path workspace directory + * @param options org options + */ + def newOrg(path: PathLike, options: OpenOptions): Org = { + // All should be options on the org, some are cached when the org is created + options.loggingLevel.foreach(LoggerOps.setLoggingLevel) + options.parser.foreach(ServerOps.setCurrentParser) + options.externalAnalysisMode.foreach(mode => + ServerOps.setExternalAnalysis(ExternalAnalysisConfiguration(mode)) + ) + options.cacheDirectory.foreach(path => { + Environment.setCacheDirOverride(Some(Some(Path(path)))) + ServerOps.setAutoFlush(path.nonEmpty) + }) + options.indexerConfiguration.foreach(values => + ServerOps.setIndexerConfiguration(IndexerConfiguration(values._1, values._2)) + ) + options.autoFlush.foreach(enabled => ServerOps.setAutoFlush(enabled)) + options.cache.foreach(enabled => if (!enabled) Environment.setCacheDirOverride(Some(None))) + options.unused.foreach(enabled => + if (!enabled) PluginsManager.removePlugins(Seq(classOf[UnusedPlugin])) + ) + LoggerOps.infoTime( s"Org created", show = true, diff --git a/jvm/src/main/scala/com/nawforce/apexlink/cmds/Check.scala b/jvm/src/main/scala/com/nawforce/apexlink/cmds/Check.scala deleted file mode 100644 index 3d9920fbf..000000000 --- a/jvm/src/main/scala/com/nawforce/apexlink/cmds/Check.scala +++ /dev/null @@ -1,259 +0,0 @@ -/* - Copyright (c) 2020 Kevin Jones, All rights reserved. - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions - are met: - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - 3. The name of the author may not be used to endorse or promote products - derived from this software without specific prior written permission. - */ - -package com.nawforce.apexlink.cmds - -import com.nawforce.apexlink.api._ -import com.nawforce.apexlink.plugins.{PluginsManager, UnusedPlugin} -import com.nawforce.pkgforce.diagnostics.{DefaultLogger, LoggerOps} -import com.nawforce.runtime.platform.Environment -import io.github.apexdevtools.api.IssueLocation - -import scala.collection.mutable -import scala.jdk.CollectionConverters._ - -/** Basic command line for exercising the project analysis */ -object Check { - final val STATUS_OK: Int = 0 - final val STATUS_ARGS: Int = 1 - final val STATUS_EXCEPTION: Int = 3 - final val STATUS_ISSUES: Int = 4 - - def usage(name: String) = - s"Usage: $name [-json] [-verbose [-unused]] [-info|-debug] [-nocache] [-depends] [-outlinesingle|-outlinemulti] " - - def run(args: Array[String]): Int = { - val flags = - Set( - "-json", - "-verbose", - "-info", - "-debug", - "-nocache", - "-unused", - "-depends", - "-outlinesingle", - "-outlinemulti" - ) - - val json = args.contains("-json") - val verbose = !json && args.contains("-verbose") - val debug = !json && args.contains("-debug") - val info = !json && !debug && args.contains("-info") - val depends = args.contains("-depends") - val noCache = args.contains("-nocache") - val unused = args.contains("-unused") - val useOutlineParserSingleThreaded = args.contains("-outlinesingle") - val useOutlineParserMultithreaded = args.contains("-outlinemulti") - - // Check we have some metadata directories to work with - val dirs = args.filterNot(flags.contains) - if (dirs.isEmpty) { - System.err.println(s"No workspace directory argument provided.") - return STATUS_ARGS - } - if (dirs.length > 1) { - System.err.println( - s"Multiple arguments provided, expected workspace directory, '${dirs.mkString(", ")}'}" - ) - return STATUS_ARGS - } - - try { - // Setup cache flushing, analysis & logging defaults - ServerOps.setAutoFlush(false) - ServerOps.setExternalAnalysis(ExternalAnalysisConfiguration(LoadAndRefreshAnalysis, Map())) - if (useOutlineParserSingleThreaded) - ServerOps.setCurrentParser(OutlineParserSingleThreaded) - else if (useOutlineParserMultithreaded) - ServerOps.setCurrentParser(OutlineParserMultithreaded) - LoggerOps.setLogger(new DefaultLogger(System.out)) - if (debug) - LoggerOps.setLoggingLevel(LoggerOps.DEBUG_LOGGING) - else if (info) - LoggerOps.setLoggingLevel(LoggerOps.INFO_LOGGING) - - // Disable loading from the cache - if (noCache) { - Environment.setCacheDirOverride(Some(None)) - } - - // Don't use unused analysis unless we have both verbose and unused flags - if (!verbose || !unused) { - PluginsManager.removePlugins(Seq(classOf[UnusedPlugin])) - } - - // Load org and flush to cache if we are using it - val org = Org.newOrg(dirs.head) - if (!noCache) { - org.flush() - } - - // Output issues - if (depends) { - if (json) { - writeDependenciesAsJSON(org) - } else { - writeDependenciesAsCSV(org) - } - STATUS_OK - } else { - writeIssues(org, json, verbose) - } - - } catch { - case ex: Throwable => - ex.printStackTrace(System.err) - STATUS_EXCEPTION - } - } - - private def writeDependenciesAsJSON(org: Org): Unit = { - val buffer = new mutable.StringBuilder() - var first = true - buffer ++= s"""{ "dependencies": [\n""" - org.getDependencies.asScala.foreach(kv => { - if (!first) - buffer ++= ",\n" - first = false - - buffer ++= s"""{ "name": "${kv._1}", "dependencies": [""" - buffer ++= kv._2.map("\"" + _ + "\"").mkString(", ") - buffer ++= s"]}" - }) - buffer ++= "]}\n" - print(buffer.mkString) - } - - private def writeDependenciesAsCSV(org: Org): Unit = { - org.getDependencies.asScala.foreach(kv => { - println(s"${kv._1}, ${kv._2.mkString(", ")}") - }) - } - - private def writeIssues(org: Org, asJSON: Boolean, includeWarnings: Boolean): Int = { - - val issues = org.issues.issuesForFiles(null, includeWarnings, 0) - val writer = if (asJSON) new JSONMessageWriter() else new TextMessageWriter() - writer.startOutput() - var hasErrors = false - var lastPath = "" - - issues.foreach(issue => { - hasErrors |= issue.isError() - if (includeWarnings || issue.isError) { - - if (issue.filePath() != lastPath) { - if (lastPath.nonEmpty) - writer.endDocument() - lastPath = issue.filePath() - writer.startDocument(lastPath) - } - - writer.writeMessage(issue.rule().name(), issue.fileLocation(), issue.message) - - } - }) - if (lastPath.nonEmpty) - writer.endDocument() - - print(writer.output) - System.out.flush() - if (hasErrors) STATUS_ISSUES else STATUS_OK - } - - private trait MessageWriter { - def startOutput(): Unit - - def startDocument(path: String): Unit - - def writeMessage(category: String, location: IssueLocation, message: String): Unit - - def endDocument(): Unit - - def output: String - } - - private class TextMessageWriter(showPath: Boolean = true) extends MessageWriter { - private val buffer = new mutable.StringBuilder() - - override def startOutput(): Unit = buffer.clear() - - override def startDocument(path: String): Unit = if (showPath) buffer ++= path + '\n' - - override def writeMessage(category: String, location: IssueLocation, message: String): Unit = - buffer ++= s"$category: ${location.displayPosition}: $message\n" - - override def endDocument(): Unit = {} - - override def output: String = buffer.toString() - } - - private class JSONMessageWriter extends MessageWriter { - private val buffer = new mutable.StringBuilder() - private var firstDocument: Boolean = _ - private var firstMessage: Boolean = _ - - override def startOutput(): Unit = { - buffer.clear() - buffer ++= s"""{ "files": [\n""" - firstDocument = true - } - - override def startDocument(path: String): Unit = { - buffer ++= (if (firstDocument) "" else ",\n") - buffer ++= s"""{ "path": "${JSON.encode(path)}", "messages": [\n""" - firstDocument = false - firstMessage = true - } - - override def writeMessage(category: String, location: IssueLocation, message: String): Unit = { - buffer ++= (if (firstMessage) "" else ",\n") - buffer ++= s"""{${locationAsJSON(location)}, "category": "$category", "message": "${JSON - .encode(message)}"}""" - firstMessage = false - } - - override def endDocument(): Unit = buffer ++= "\n]}" - - override def output: String = { - buffer ++= "]}\n" - buffer.toString() - } - - private def locationAsJSON(location: IssueLocation): String = - s""""start": {"line": ${location.startLineNumber()}, "offset": ${location - .startCharOffset()} }, "end": {"line": ${location.endLineNumber()}, "offset": ${location - .endCharOffset()} }""" - } - - object JSON { - def encode(value: String): String = { - val buf = new mutable.StringBuilder() - value.foreach { - case '"' => buf.append("\\\"") - case '\\' => buf.append("\\\\") - case '\b' => buf.append("\\b") - case '\f' => buf.append("\\f") - case '\n' => buf.append("\\n") - case '\r' => buf.append("\\r") - case '\t' => buf.append("\\t") - case char if char < 0x20 => buf.append("\\u%04x".format(char: Int)) - case char => buf.append(char) - } - buf.mkString - } - } - -} diff --git a/jvm/src/main/scala/com/nawforce/apexlink/cmds/Server.scala b/jvm/src/main/scala/com/nawforce/apexlink/cmds/Server.scala deleted file mode 100644 index 7fe87913a..000000000 --- a/jvm/src/main/scala/com/nawforce/apexlink/cmds/Server.scala +++ /dev/null @@ -1,31 +0,0 @@ -/* - Copyright (c) 2020 Kevin Jones, All rights reserved. - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions - are met: - 1. Redistributions of source code must retain the above copyright - notice, this list of conditions and the following disclaimer. - 2. Redistributions in binary form must reproduce the above copyright - notice, this list of conditions and the following disclaimer in the - documentation and/or other materials provided with the distribution. - 3. The name of the author may not be used to endorse or promote products - derived from this software without specific prior written permission. - */ - -package com.nawforce.apexlink.cmds - -import com.nawforce.apexlink.rpc.RPCServer - -object Server { - - def main(args: Array[String]): Unit = { - try { - new RPCServer().run() - } catch { - case ex: Throwable => - ex.printStackTrace() - System.exit(-1) - } - } - -} diff --git a/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPI.scala b/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPI.scala index 1f4f11b8c..8c8a5dcad 100644 --- a/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPI.scala +++ b/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPI.scala @@ -191,8 +191,11 @@ case class OpenOptions private ( parser: Option[String] = None, loggingLevel: Option[String] = None, externalAnalysisMode: Option[String] = None, + cache: Option[Boolean] = None, cacheDirectory: Option[String] = None, - indexerConfiguration: Option[(Long, Long)] = None + indexerConfiguration: Option[(Long, Long)] = None, + autoFlush: Option[Boolean] = None, + unused: Option[Boolean] = None ) { def withParser(name: String): OpenOptions = { copy(parser = Some(name)) @@ -210,6 +213,10 @@ case class OpenOptions private ( copy(cacheDirectory = Some(path)) } + def withCache(enabled: Boolean): OpenOptions = { + copy(cache = Some(enabled)) + } + def withIndexerConfiguration( rescanTriggerTimeMs: Long, quietPeriodForRescanMs: Long @@ -217,13 +224,20 @@ case class OpenOptions private ( copy(indexerConfiguration = Some((rescanTriggerTimeMs, quietPeriodForRescanMs))) } + def withAutoFlush(enabled: Boolean): OpenOptions = { + copy(autoFlush = Some(enabled)) + } + + def withUnused(enabled: Boolean): OpenOptions = { + copy(unused = Some(enabled)) + } } object OpenOptions { implicit val rw: RW[OpenOptions] = macroRW def default(): OpenOptions = { - new OpenOptions(None, None, None, None, None) + new OpenOptions() } } diff --git a/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPIImpl.scala b/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPIImpl.scala index c6d6da79e..f976af7fe 100644 --- a/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPIImpl.scala +++ b/jvm/src/main/scala/com/nawforce/apexlink/rpc/OrgAPIImpl.scala @@ -14,12 +14,7 @@ package com.nawforce.apexlink.rpc -import com.nawforce.apexlink.api.{ - ExternalAnalysisConfiguration, - IndexerConfiguration, - Org, - ServerOps -} +import com.nawforce.apexlink.api.{ExternalAnalysisConfiguration, Org, ServerOps} import com.nawforce.apexlink.org.{OPM, OrgInfo} import com.nawforce.pkgforce.diagnostics.LoggerOps import com.nawforce.pkgforce.names.TypeIdentifier @@ -33,9 +28,9 @@ trait APIRequest { def process(org: OrgQueue): Unit } -class OrgQueue(path: String) { +class OrgQueue(path: String, options: OpenOptions) { self => - val org: Org = Org.newOrg(path) + val org: Org = Org.newOrg(Path(path), options) private val queue = new LinkedBlockingQueue[APIRequest]() private val dispatcher = new APIRequestDispatcher() @@ -528,9 +523,9 @@ object GetTestMethodItems { object OrgQueue { private var _instance: Option[OrgQueue] = None - def open(path: String): OrgQueue = { + def open(path: String, options: OpenOptions = OpenOptions.default()): OrgQueue = { synchronized { - _instance = Some(new OrgQueue(path)) + _instance = Some(new OrgQueue(path, options)) _instance.get } } @@ -574,19 +569,7 @@ class OrgAPIImpl extends OrgAPI { } override def open(directory: String, options: OpenOptions): Future[OpenResult] = { - options.loggingLevel.foreach(LoggerOps.setLoggingLevel) - options.parser.foreach(ServerOps.setCurrentParser) - options.externalAnalysisMode.foreach(mode => - ServerOps.setExternalAnalysis(ExternalAnalysisConfiguration(mode)) - ) - options.cacheDirectory.foreach(path => { - Environment.setCacheDirOverride(Some(Some(Path(path)))) - ServerOps.setAutoFlush(path.nonEmpty) - }) - options.indexerConfiguration.foreach(values => - ServerOps.setIndexerConfiguration(IndexerConfiguration(values._1, values._2)) - ) - OrgQueue.open(directory) + OrgQueue.open(directory, options) OpenRequest(OrgQueue.instance()) } diff --git a/jvm/src/main/scala/io/github/apexdevtools/apexls/Indexer.scala b/jvm/src/main/scala/io/github/apexdevtools/apexls/Indexer.scala deleted file mode 100644 index adfc5f812..000000000 --- a/jvm/src/main/scala/io/github/apexdevtools/apexls/Indexer.scala +++ /dev/null @@ -1,13 +0,0 @@ -/* - * Copyright (c) 2022 FinancialForce.com, inc. All rights reserved. - */ - -package io.github.apexdevtools.apexls - -import com.nawforce.runtime.cmds.{Indexer => ApexLinkIndexer} - -object Indexer { - def main(args: Array[String]): Unit = { - ApexLinkIndexer.main(args) - } -} diff --git a/jvm/src/main/scala/io/github/apexdevtools/apexls/Main.scala b/jvm/src/main/scala/io/github/apexdevtools/apexls/Main.scala index eb7d72747..c29f7badf 100644 --- a/jvm/src/main/scala/io/github/apexdevtools/apexls/Main.scala +++ b/jvm/src/main/scala/io/github/apexdevtools/apexls/Main.scala @@ -1,13 +1,279 @@ /* - * Copyright (c) 2022 FinancialForce.com, inc. All rights reserved. + Copyright (c) 2020 Kevin Jones, All rights reserved. + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. */ package io.github.apexdevtools.apexls -import com.nawforce.apexlink.ApexLink +import com.nawforce.apexlink.api._ +import com.nawforce.apexlink.rpc.OpenOptions +import com.nawforce.runtime.platform.Path +import io.github.apexdevtools.api.IssueLocation +import mainargs.{Flag, Leftover, ParserForClass, arg} +import scala.collection.immutable.ArraySeq +import scala.collection.mutable +import scala.jdk.CollectionConverters._ + +/** Command line for running project analysis. + * + * Defaults to reporting issue but can also be used to report dependency information. + */ object Main { + private final val STATUS_OK: Int = 0 + private final val STATUS_ARGS: Int = 1 + private final val STATUS_EXCEPTION: Int = 3 + private final val STATUS_ISSUES: Int = 4 + + private case class ConfigArgs( + @arg(short = 'd', doc = "Report dependencies rather than issues") + depends: Flag, + @arg(short = 'w', doc = "Output warning issues") + warnings: Flag, + @arg(short = 'u', doc = "Output unused warning issues, requires --warnings") + unused: Flag, + @arg(short = 'i', doc = "Enable info logging") + info: Flag, + @arg(short = 'd', doc = "Enable debug logging") + debug: Flag, + @arg(short = 'n', doc = "Disable cache use") + nocache: Flag, + @arg(short = 'j', doc = "Generate json output, disables info/debug logging") + json: Flag, + @arg(doc = "SFDX Workspace directory path") + dirs: Leftover[String] + ) + + private class Config(args: ConfigArgs) { + + val dirs: Seq[String] = args.dirs.value + + val asOpenOptions: OpenOptions = { + OpenOptions + .default() + .withAutoFlush(enabled = false) + .withExternalAnalysisMode(LoadAndRefreshAnalysis.shortName) + .withLoggingLevel( + if (args.debug.value) "debug" + else if (args.info.value) "info" + else "none" + ) + .withCache(!args.nocache.value) + .withUnused(args.unused.value) + } + + val shouldManualFlush: Boolean = !args.nocache.value + + val outputDependencies: Boolean = args.depends.value + + val asJSON: Boolean = args.json.value + + val warnings: Boolean = args.warnings.value + } + + private object Config { + def apply(args: Array[String]): Config = { + new Config( + ParserForClass[ConfigArgs] + .constructOrExit(ArraySeq.unsafeWrapArray(args), customName = classOf[Main].getName) + ) + } + } + def main(args: Array[String]): Unit = { - ApexLink.main(args) + System.exit(run(args)) } + + def run(args: Array[String]): Int = { + try { + val config = Config(args) + + // Check we have some metadata directories to work with + val dirs = config.dirs + if (dirs.isEmpty) { + System.err.println(s"No workspace directory argument provided.") + return STATUS_ARGS + } + if (dirs.length > 1) { + System.err.println( + s"Multiple arguments provided, expected single workspace directory, '${dirs.mkString(", ")}'}" + ) + return STATUS_ARGS + } + + // Load org and flush to cache if we are using it + val workspace = config.dirs.head + val org = Org.newOrg(Path(workspace), config.asOpenOptions) + if (config.shouldManualFlush) { + org.flush() + } + + // Output issues + if (config.outputDependencies) { + if (config.asJSON) { + writeDependenciesAsJSON(org) + } else { + writeDependenciesAsCSV(org) + } + STATUS_OK + } else { + writeIssues(org, config.asJSON, config.warnings) + } + + } catch { + case ex: Throwable => + ex.printStackTrace(System.err) + STATUS_EXCEPTION + } + } + + private def writeDependenciesAsJSON(org: Org): Unit = { + val buffer = new mutable.StringBuilder() + var first = true + buffer ++= s"""{ "dependencies": [\n""" + org.getDependencies.asScala.foreach(kv => { + if (!first) + buffer ++= ",\n" + first = false + + buffer ++= s"""{ "name": "${kv._1}", "dependencies": [""" + buffer ++= kv._2.map("\"" + _ + "\"").mkString(", ") + buffer ++= s"]}" + }) + buffer ++= "]}\n" + print(buffer.mkString) + } + + private def writeDependenciesAsCSV(org: Org): Unit = { + org.getDependencies.asScala.foreach(kv => { + println(s"${kv._1}, ${kv._2.mkString(", ")}") + }) + } + + private def writeIssues(org: Org, asJSON: Boolean, includeWarnings: Boolean): Int = { + + val issues = org.issues.issuesForFiles(null, includeWarnings, 0) + val writer = if (asJSON) new JSONMessageWriter() else new TextMessageWriter() + writer.startOutput() + var hasErrors = false + var lastPath = "" + + issues.foreach(issue => { + hasErrors |= issue.isError() + if (includeWarnings || issue.isError) { + + if (issue.filePath() != lastPath) { + if (lastPath.nonEmpty) + writer.endDocument() + lastPath = issue.filePath() + writer.startDocument(lastPath) + } + + writer.writeMessage(issue.rule().name(), issue.fileLocation(), issue.message) + + } + }) + if (lastPath.nonEmpty) + writer.endDocument() + + print(writer.output) + System.out.flush() + if (hasErrors) STATUS_ISSUES else STATUS_OK + } + + private trait MessageWriter { + def startOutput(): Unit + + def startDocument(path: String): Unit + + def writeMessage(category: String, location: IssueLocation, message: String): Unit + + def endDocument(): Unit + + def output: String + } + + private class TextMessageWriter(showPath: Boolean = true) extends MessageWriter { + private val buffer = new mutable.StringBuilder() + + override def startOutput(): Unit = buffer.clear() + + override def startDocument(path: String): Unit = if (showPath) buffer ++= path + '\n' + + override def writeMessage(category: String, location: IssueLocation, message: String): Unit = + buffer ++= s"$category: ${location.displayPosition}: $message\n" + + override def endDocument(): Unit = {} + + override def output: String = buffer.toString() + } + + private class JSONMessageWriter extends MessageWriter { + private val buffer = new mutable.StringBuilder() + private var firstDocument: Boolean = _ + private var firstMessage: Boolean = _ + + override def startOutput(): Unit = { + buffer.clear() + buffer ++= s"""{ "files": [\n""" + firstDocument = true + } + + override def startDocument(path: String): Unit = { + buffer ++= (if (firstDocument) "" else ",\n") + buffer ++= s"""{ "path": "${JSON.encode(path)}", "messages": [\n""" + firstDocument = false + firstMessage = true + } + + override def writeMessage(category: String, location: IssueLocation, message: String): Unit = { + buffer ++= (if (firstMessage) "" else ",\n") + buffer ++= s"""{${locationAsJSON(location)}, "category": "$category", "message": "${JSON + .encode(message)}"}""" + firstMessage = false + } + + override def endDocument(): Unit = buffer ++= "\n]}" + + override def output: String = { + buffer ++= "]}\n" + buffer.toString() + } + + private def locationAsJSON(location: IssueLocation): String = + s""""start": {"line": ${location.startLineNumber()}, "offset": ${location + .startCharOffset()} }, "end": {"line": ${location.endLineNumber()}, "offset": ${location + .endCharOffset()} }""" + } + + object JSON { + def encode(value: String): String = { + val buf = new mutable.StringBuilder() + value.foreach { + case '"' => buf.append("\\\"") + case '\\' => buf.append("\\\\") + case '\b' => buf.append("\\b") + case '\f' => buf.append("\\f") + case '\n' => buf.append("\\n") + case '\r' => buf.append("\\r") + case '\t' => buf.append("\\t") + case char if char < 0x20 => buf.append("\\u%04x".format(char: Int)) + case char => buf.append(char) + } + buf.mkString + } + } +} + +private class Main { + // To allow getting classOf, see above } diff --git a/jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala b/jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala index b8bba37f8..d5fb095cf 100644 --- a/jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala +++ b/jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala @@ -3,18 +3,13 @@ */ package io.github.apexdevtools.apexls -import com.nawforce.apexlink.api.{ - ExternalAnalysisConfiguration, - LoadAndRefreshAnalysis, - Org, - ServerOps -} -import com.nawforce.apexlink.plugins.{PluginsManager, UnusedPlugin} -import com.nawforce.pkgforce.diagnostics.{DefaultLogger, LoggerOps} -import com.nawforce.runtime.platform.{Environment, Path} +import com.nawforce.apexlink.api.{LoadAndRefreshAnalysis, Org} +import com.nawforce.apexlink.rpc.OpenOptions +import com.nawforce.runtime.platform.Path +import mainargs.{Flag, Leftover, ParserForClass, arg} import java.time.Instant -import scala.collection.immutable +import scala.collection.immutable.ArraySeq import scala.xml.XML /** Generate a report using PMD format XML, see @@ -26,62 +21,83 @@ object PMDReport { private final val STATUS_ARGS: Int = 1 private final val STATUS_EXCEPTION: Int = 3 - def main(args: Array[String]): Unit = { - System.exit(run(args)) + private case class ConfigArgs( + @arg(short = 'w', doc = "Output warning issues") + warnings: Flag, + @arg(short = 'u', doc = "Output unused warning issues, requires --warnings") + unused: Flag, + @arg(short = 'i', doc = "Enable info logging") + info: Flag, + @arg(short = 'd', doc = "Enable debug logging") + debug: Flag, + @arg(short = 'n', doc = "Disable cache use") + nocache: Flag, + @arg(doc = "SFDX Workspace directory path") + dirs: Leftover[String] + ) + + private class Config(args: ConfigArgs) { + + val dirs: Seq[String] = args.dirs.value + + val asOpenOptions: OpenOptions = { + OpenOptions + .default() + .withAutoFlush(enabled = false) + .withExternalAnalysisMode(LoadAndRefreshAnalysis.shortName) + .withLoggingLevel( + if (args.debug.value) "debug" + else if (args.info.value) "info" + else "none" + ) + .withCache(!args.nocache.value) + .withUnused(args.unused.value) + } + + val shouldManualFlush: Boolean = !args.nocache.value + + val writeWarnings: Boolean = args.warnings.value } - def run(args: Array[String]): Int = { - val flags = - Set("-verbose", "-unused", "-nocache", "-info", "-debug") - - val verbose = args.contains("-verbose") - val unused = verbose && args.contains("-unused") - val noCache = args.contains("-nocache") - val debug = args.contains("-debug") - val info = !debug && args.contains("-info") - - // Check we have some metadata directories to work with - val dirs = args.filterNot(flags.contains) - if (dirs.isEmpty) { - System.err.println(s"No workspace directory argument provided.") - return STATUS_ARGS - } - if (dirs.length > 1) { - System.err.println( - s"Multiple arguments provided, expected workspace directory, '${dirs.mkString(", ")}'}" + private object Config { + def apply(args: Array[String]): Config = { + new Config( + ParserForClass[ConfigArgs] + .constructOrExit(ArraySeq.unsafeWrapArray(args), customName = classOf[PMDReport].getName) ) - return STATUS_ARGS } + } + def main(args: Array[String]): Unit = { + System.exit(run(args)) + } + + def run(args: Array[String]): Int = { try { - // Setup cache flushing, analysis & logging defaults - ServerOps.setAutoFlush(false) - ServerOps.setExternalAnalysis(ExternalAnalysisConfiguration(LoadAndRefreshAnalysis, Map())) - LoggerOps.setLogger(new DefaultLogger(System.err)) - if (debug) - LoggerOps.setLoggingLevel(LoggerOps.DEBUG_LOGGING) - else if (info) - LoggerOps.setLoggingLevel(LoggerOps.INFO_LOGGING) - - // Disable loading from the cache - if (noCache) { - Environment.setCacheDirOverride(Some(None)) - } + val config = Config(args) - // Don't use unused analysis unless we have both verbose and unused flags - if (!verbose || !unused) { - PluginsManager.removePlugins(immutable.Seq(classOf[UnusedPlugin])) + // Check we have some metadata directories to work with + if (config.dirs.isEmpty) { + System.err.println(s"No workspace directory argument provided.") + return STATUS_ARGS + } + if (config.dirs.length > 1) { + System.err.println( + s"Multiple arguments provided, expected single workspace directory, '${config.dirs.mkString(", ")}'}" + ) + return STATUS_ARGS } // Load org and flush to cache if we are using it - val org = Org.newOrg(dirs.head) - if (!noCache) { + val workspace = config.dirs.head + val org = Org.newOrg(Path(workspace), config.asOpenOptions) + if (config.shouldManualFlush) { org.flush() } // Output report - val outputPath = Path(dirs.head).join(REPORT_NAME) - writeIssues(outputPath, org, verbose) + val outputPath = Path(workspace).join(REPORT_NAME) + writeIssues(outputPath, org, config.writeWarnings) } catch { case ex: Throwable => @@ -133,3 +149,7 @@ object PMDReport { STATUS_OK } } + +class PMDReport { + // To allow getting classOf, see use above +} diff --git a/jvm/src/main/scala/io/github/apexdevtools/apexls/Server.scala b/jvm/src/main/scala/io/github/apexdevtools/apexls/Server.scala index aa77d4714..27a1b86be 100644 --- a/jvm/src/main/scala/io/github/apexdevtools/apexls/Server.scala +++ b/jvm/src/main/scala/io/github/apexdevtools/apexls/Server.scala @@ -4,10 +4,20 @@ package io.github.apexdevtools.apexls -import com.nawforce.apexlink.cmds.{Server => ApexLinkServer} +import com.nawforce.apexlink.rpc.RPCServer +/** Start apex-ls as an RPC server. + * + * RPC messages are passed over stdin/stdout. See @OrgAPI for details of messages supported. + */ object Server { def main(args: Array[String]): Unit = { - ApexLinkServer.main(args) + try { + new RPCServer().run() + } catch { + case ex: Throwable => + ex.printStackTrace() + System.exit(-1) + } } } From 8739be828ff100d0b93968f26493bf036ca5c712 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 26 Oct 2023 19:46:26 +0100 Subject: [PATCH 2/3] Improve command lines some more --- js/npm/src/__tests__/system/SampleCheckSys.ts | 8 +- .../{Main.scala => CheckForIssues.scala} | 216 +++++++++--------- .../apexls/DependencyReport.scala | 133 +++++++++++ .../apexdevtools/apexls/PMDReport.scala | 155 ------------- 4 files changed, 245 insertions(+), 267 deletions(-) rename jvm/src/main/scala/io/github/apexdevtools/apexls/{Main.scala => CheckForIssues.scala} (58%) create mode 100644 jvm/src/main/scala/io/github/apexdevtools/apexls/DependencyReport.scala delete mode 100644 jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala diff --git a/js/npm/src/__tests__/system/SampleCheckSys.ts b/js/npm/src/__tests__/system/SampleCheckSys.ts index 9ae197170..67b7bfbe1 100644 --- a/js/npm/src/__tests__/system/SampleCheckSys.ts +++ b/js/npm/src/__tests__/system/SampleCheckSys.ts @@ -138,10 +138,10 @@ describe("Check samples", () => { [ "-cp", "jvm/target/scala-2.13/*:jvm/target/scala-2.13/apex-ls_2.13-*.jar", - "io.github.apexdevtools.apexls.Main", - "-verbose", - "-nocache", - "-outlinemulti", + "io.github.apexdevtools.apexls.CheckForIssues", + "-d", "warnings", + "-n", + "-w", path, ], { diff --git a/jvm/src/main/scala/io/github/apexdevtools/apexls/Main.scala b/jvm/src/main/scala/io/github/apexdevtools/apexls/CheckForIssues.scala similarity index 58% rename from jvm/src/main/scala/io/github/apexdevtools/apexls/Main.scala rename to jvm/src/main/scala/io/github/apexdevtools/apexls/CheckForIssues.scala index c29f7badf..987335a83 100644 --- a/jvm/src/main/scala/io/github/apexdevtools/apexls/Main.scala +++ b/jvm/src/main/scala/io/github/apexdevtools/apexls/CheckForIssues.scala @@ -18,115 +18,105 @@ import com.nawforce.apexlink.api._ import com.nawforce.apexlink.rpc.OpenOptions import com.nawforce.runtime.platform.Path import io.github.apexdevtools.api.IssueLocation -import mainargs.{Flag, Leftover, ParserForClass, arg} +import mainargs.{Flag, ParserForMethods, arg, main} +import java.time.Instant +import scala.annotation.unused import scala.collection.immutable.ArraySeq import scala.collection.mutable -import scala.jdk.CollectionConverters._ /** Command line for running project analysis. * * Defaults to reporting issue but can also be used to report dependency information. */ -object Main { +object CheckForIssues { private final val STATUS_OK: Int = 0 private final val STATUS_ARGS: Int = 1 private final val STATUS_EXCEPTION: Int = 3 private final val STATUS_ISSUES: Int = 4 - private case class ConfigArgs( - @arg(short = 'd', doc = "Report dependencies rather than issues") - depends: Flag, - @arg(short = 'w', doc = "Output warning issues") - warnings: Flag, - @arg(short = 'u', doc = "Output unused warning issues, requires --warnings") - unused: Flag, - @arg(short = 'i', doc = "Enable info logging") - info: Flag, - @arg(short = 'd', doc = "Enable debug logging") - debug: Flag, + @unused + @main(name = "io.github.apexdevtools.apexls.CheckForIssues") + def mainWithArgs( + @arg(short = 'f', doc = "Output format text (default), json or pmd") + format: String = "text", + @arg(short = 'l', doc = "Text output logging level, none (default), info or debug") + logging: String = "none", + @arg(short = 'd', doc = "Detail level, errors (default), warnings, unused") + detail: String = "errors", @arg(short = 'n', doc = "Disable cache use") nocache: Flag, - @arg(short = 'j', doc = "Generate json output, disables info/debug logging") - json: Flag, - @arg(doc = "SFDX Workspace directory path") - dirs: Leftover[String] - ) - - private class Config(args: ConfigArgs) { - - val dirs: Seq[String] = args.dirs.value - - val asOpenOptions: OpenOptions = { - OpenOptions - .default() - .withAutoFlush(enabled = false) - .withExternalAnalysisMode(LoadAndRefreshAnalysis.shortName) - .withLoggingLevel( - if (args.debug.value) "debug" - else if (args.info.value) "info" - else "none" - ) - .withCache(!args.nocache.value) - .withUnused(args.unused.value) - } - - val shouldManualFlush: Boolean = !args.nocache.value - - val outputDependencies: Boolean = args.depends.value - - val asJSON: Boolean = args.json.value - - val warnings: Boolean = args.warnings.value - } - - private object Config { - def apply(args: Array[String]): Config = { - new Config( - ParserForClass[ConfigArgs] - .constructOrExit(ArraySeq.unsafeWrapArray(args), customName = classOf[Main].getName) - ) - } + @arg(short = 'w', doc = "Workspace directory path, defaults to current directory") + workspace: String = "" + ): Unit = { + System.exit(run(format, logging, detail, nocache.value, workspace)) } def main(args: Array[String]): Unit = { - System.exit(run(args)) + ParserForMethods(this).runOrExit(ArraySeq.unsafeWrapArray(args)) } - def run(args: Array[String]): Int = { + def run( + format: String, + logging: String, + detail: String, + nocache: Boolean, + directory: String + ): Int = { try { - val config = Config(args) - - // Check we have some metadata directories to work with - val dirs = config.dirs - if (dirs.isEmpty) { - System.err.println(s"No workspace directory argument provided.") - return STATUS_ARGS + val workspace = Path(directory) + val outputFormat = format match { + case "text" | "json" | "pmd" => format + case _ => + System.err.println( + s"Unknown output format provided '$format', should be 'text', 'json' or 'pmd'" + ) + return STATUS_ARGS } - if (dirs.length > 1) { - System.err.println( - s"Multiple arguments provided, expected single workspace directory, '${dirs.mkString(", ")}'}" - ) - return STATUS_ARGS + + val loggingLevel = + if (outputFormat != "text") + "none" + else + logging match { + case "none" | "info" | "debug" => logging + case _ => + System.err.println( + s"Unknown logging level provided '$logging', should be 'none', 'info' or 'debug'" + ) + return STATUS_ARGS + } + + val detailLevel = detail match { + case "errors" | "warnings" | "unused" => detail + case _ => + System.err.println( + s"Unknown detail level provided '$detail', should be 'errors', 'warnings' or 'unused'" + ) + return STATUS_ARGS } + val options = OpenOptions + .default() + .withParser("OutlineSingle") + .withAutoFlush(enabled = false) + .withExternalAnalysisMode(LoadAndRefreshAnalysis.shortName) + .withLoggingLevel(loggingLevel) + .withCache(!nocache) + .withUnused(detailLevel == "unused") + // Load org and flush to cache if we are using it - val workspace = config.dirs.head - val org = Org.newOrg(Path(workspace), config.asOpenOptions) - if (config.shouldManualFlush) { + val org = Org.newOrg(Path(workspace), options) + if (!nocache) { org.flush() } // Output issues - if (config.outputDependencies) { - if (config.asJSON) { - writeDependenciesAsJSON(org) - } else { - writeDependenciesAsCSV(org) - } - STATUS_OK + val includeWarnings = detailLevel == "warnings" || detailLevel == "unused" + if (outputFormat == "pmd") { + writeIssuesPMD(org, includeWarnings) } else { - writeIssues(org, config.asJSON, config.warnings) + writeIssues(org, outputFormat == "json", includeWarnings) } } catch { @@ -136,29 +126,6 @@ object Main { } } - private def writeDependenciesAsJSON(org: Org): Unit = { - val buffer = new mutable.StringBuilder() - var first = true - buffer ++= s"""{ "dependencies": [\n""" - org.getDependencies.asScala.foreach(kv => { - if (!first) - buffer ++= ",\n" - first = false - - buffer ++= s"""{ "name": "${kv._1}", "dependencies": [""" - buffer ++= kv._2.map("\"" + _ + "\"").mkString(", ") - buffer ++= s"]}" - }) - buffer ++= "]}\n" - print(buffer.mkString) - } - - private def writeDependenciesAsCSV(org: Org): Unit = { - org.getDependencies.asScala.foreach(kv => { - println(s"${kv._1}, ${kv._2.mkString(", ")}") - }) - } - private def writeIssues(org: Org, asJSON: Boolean, includeWarnings: Boolean): Int = { val issues = org.issues.issuesForFiles(null, includeWarnings, 0) @@ -186,7 +153,6 @@ object Main { writer.endDocument() print(writer.output) - System.out.flush() if (hasErrors) STATUS_ISSUES else STATUS_OK } @@ -255,7 +221,45 @@ object Main { .endCharOffset()} }""" } - object JSON { + private def writeIssuesPMD(org: Org, includeWarnings: Boolean): Int = { + + val issues = org.issues.issuesForFiles(null, includeWarnings, 0) + val issuesByFile = issues.groupBy(_.filePath()) + val files = issuesByFile.map(kv => { + val path = kv._1 + val issues = kv._2 + + val violations = issues.map(issue => { + + {issue.message()} + + }) + + {violations} + + }) + + val timestamp = Instant.now().toString + val pmd = + {files} + + + val printer = new scala.xml.PrettyPrinter(80, 2) + println(printer.format(pmd)) + STATUS_OK + } + + private object JSON { def encode(value: String): String = { val buf = new mutable.StringBuilder() value.foreach { @@ -273,7 +277,3 @@ object Main { } } } - -private class Main { - // To allow getting classOf, see above -} diff --git a/jvm/src/main/scala/io/github/apexdevtools/apexls/DependencyReport.scala b/jvm/src/main/scala/io/github/apexdevtools/apexls/DependencyReport.scala new file mode 100644 index 000000000..a9cc7ca08 --- /dev/null +++ b/jvm/src/main/scala/io/github/apexdevtools/apexls/DependencyReport.scala @@ -0,0 +1,133 @@ +/* + Copyright (c) 2020 Kevin Jones, All rights reserved. + Redistribution and use in source and binary forms, with or without + modification, are permitted provided that the following conditions + are met: + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + 2. Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + 3. The name of the author may not be used to endorse or promote products + derived from this software without specific prior written permission. + */ + +package io.github.apexdevtools.apexls + +import com.nawforce.apexlink.api._ +import com.nawforce.apexlink.rpc.OpenOptions +import com.nawforce.runtime.platform.Path +import mainargs.{Flag, ParserForMethods, arg, main} + +import scala.annotation.unused +import scala.collection.immutable.ArraySeq +import scala.collection.mutable +import scala.jdk.CollectionConverters._ + +/** Command line for running project analysis. + * + * Defaults to reporting issue but can also be used to report dependency information. + */ +object DependencyReport { + private final val STATUS_OK: Int = 0 + private final val STATUS_ARGS: Int = 1 + private final val STATUS_EXCEPTION: Int = 3 + + @unused + @main(name = "io.github.apexdevtools.apexls.DependencyReport") + def mainWithArgs( + @arg(short = 'f', doc = "Output format text (default) or json") + format: String = "text", + @arg(short = 'l', doc = "Text output logging level, none (default), info or debug") + logging: String = "none", + @arg(short = 'n', doc = "Disable cache use") + nocache: Flag, + @arg(short = 'w', doc = "Workspace directory path, defaults to current directory") + workspace: String = "" + ): Unit = { + System.exit(run(format, logging, nocache.value, workspace)) + } + + def main(args: Array[String]): Unit = { + ParserForMethods(this).runOrExit(ArraySeq.unsafeWrapArray(args)) + } + + def run(format: String, logging: String, nocache: Boolean, directory: String): Int = { + try { + val workspace = Path(directory) + val outputFormat = format match { + case "text" | "json" => format + case _ => + System.err.println( + s"Unknown output format provided '$format', should be 'text' or 'json'" + ) + return STATUS_ARGS + } + + val loggingLevel = + if (outputFormat != "text") + "none" + else + logging match { + case "none" | "info" | "debug" => logging + case _ => + System.err.println( + s"Unknown logging level provided '$logging', should be 'none', 'info' or 'debug'" + ) + return STATUS_ARGS + } + + val options = OpenOptions + .default() + .withParser("OutlineSingle") + .withAutoFlush(enabled = false) + .withExternalAnalysisMode(LoadAndRefreshAnalysis.shortName) + .withLoggingLevel(loggingLevel) + .withCache(!nocache) + .withUnused(enabled = false) + + // Load org and flush to cache if we are using it + val org = Org.newOrg(Path(workspace), options) + if (!nocache) { + org.flush() + } + + // Output issues + if (outputFormat == "json") { + writeDependenciesAsJSON(org) + } else { + writeDependenciesAsCSV(org) + } + STATUS_OK + + } catch { + case ex: Throwable => + ex.printStackTrace(System.err) + STATUS_EXCEPTION + } + } + + private def writeDependenciesAsJSON(org: Org): Unit = { + val buffer = new mutable.StringBuilder() + var first = true + buffer ++= s"""{ "dependencies": [\n""" + org.getDependencies.asScala.foreach(kv => { + if (!first) + buffer ++= ",\n" + first = false + + buffer ++= s"""{ "name": "${kv._1}", "dependencies": [""" + buffer ++= kv._2.map("\"" + _ + "\"").mkString(", ") + buffer ++= s"]}" + }) + buffer ++= "]}\n" + print(buffer.mkString) + } + + private def writeDependenciesAsCSV(org: Org): Unit = { + org.getDependencies.asScala.foreach(kv => { + println(s"${kv._1}, ${kv._2.mkString(", ")}") + }) + } + +} diff --git a/jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala b/jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala deleted file mode 100644 index d5fb095cf..000000000 --- a/jvm/src/main/scala/io/github/apexdevtools/apexls/PMDReport.scala +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (c) 2022 FinancialForce.com, inc. All rights reserved. - */ -package io.github.apexdevtools.apexls - -import com.nawforce.apexlink.api.{LoadAndRefreshAnalysis, Org} -import com.nawforce.apexlink.rpc.OpenOptions -import com.nawforce.runtime.platform.Path -import mainargs.{Flag, Leftover, ParserForClass, arg} - -import java.time.Instant -import scala.collection.immutable.ArraySeq -import scala.xml.XML - -/** Generate a report using PMD format XML, see - * https://github.com/pmd/pmd/blob/master/pmd-core/src/main/resources/report_2_0_0.xsd - */ -object PMDReport { - private final val REPORT_NAME = "apex-ls-pmd-report.xml" - private final val STATUS_OK: Int = 0 - private final val STATUS_ARGS: Int = 1 - private final val STATUS_EXCEPTION: Int = 3 - - private case class ConfigArgs( - @arg(short = 'w', doc = "Output warning issues") - warnings: Flag, - @arg(short = 'u', doc = "Output unused warning issues, requires --warnings") - unused: Flag, - @arg(short = 'i', doc = "Enable info logging") - info: Flag, - @arg(short = 'd', doc = "Enable debug logging") - debug: Flag, - @arg(short = 'n', doc = "Disable cache use") - nocache: Flag, - @arg(doc = "SFDX Workspace directory path") - dirs: Leftover[String] - ) - - private class Config(args: ConfigArgs) { - - val dirs: Seq[String] = args.dirs.value - - val asOpenOptions: OpenOptions = { - OpenOptions - .default() - .withAutoFlush(enabled = false) - .withExternalAnalysisMode(LoadAndRefreshAnalysis.shortName) - .withLoggingLevel( - if (args.debug.value) "debug" - else if (args.info.value) "info" - else "none" - ) - .withCache(!args.nocache.value) - .withUnused(args.unused.value) - } - - val shouldManualFlush: Boolean = !args.nocache.value - - val writeWarnings: Boolean = args.warnings.value - } - - private object Config { - def apply(args: Array[String]): Config = { - new Config( - ParserForClass[ConfigArgs] - .constructOrExit(ArraySeq.unsafeWrapArray(args), customName = classOf[PMDReport].getName) - ) - } - } - - def main(args: Array[String]): Unit = { - System.exit(run(args)) - } - - def run(args: Array[String]): Int = { - try { - val config = Config(args) - - // Check we have some metadata directories to work with - if (config.dirs.isEmpty) { - System.err.println(s"No workspace directory argument provided.") - return STATUS_ARGS - } - if (config.dirs.length > 1) { - System.err.println( - s"Multiple arguments provided, expected single workspace directory, '${config.dirs.mkString(", ")}'}" - ) - return STATUS_ARGS - } - - // Load org and flush to cache if we are using it - val workspace = config.dirs.head - val org = Org.newOrg(Path(workspace), config.asOpenOptions) - if (config.shouldManualFlush) { - org.flush() - } - - // Output report - val outputPath = Path(workspace).join(REPORT_NAME) - writeIssues(outputPath, org, config.writeWarnings) - - } catch { - case ex: Throwable => - ex.printStackTrace(System.err) - STATUS_EXCEPTION - } - } - - private def writeIssues(outputPath: Path, org: Org, includeWarnings: Boolean): Int = { - - val issues = org.issues.issuesForFiles(null, includeWarnings, 0) - val issuesByFile = issues.groupBy(_.filePath()) - val files = issuesByFile.map(kv => { - val path = kv._1 - val issues = kv._2 - - val violations = issues.map(issue => { - - {issue.message()} - - }) - {violations} - }) - - val timestamp = Instant.now().toString - val pmd = - {files} - - - val printer = new scala.xml.PrettyPrinter(80, 2) - XML.save( - outputPath.toString, - XML.loadString(printer.format(pmd)), - "UTF-8", - xmlDecl = true, - null - ) - STATUS_OK - } -} - -class PMDReport { - // To allow getting classOf, see use above -} From 3307dc16b8df7a484fe84b01fc04593cbfb6052e Mon Sep 17 00:00:00 2001 From: Peter Wright Date: Fri, 10 Nov 2023 12:20:00 +0000 Subject: [PATCH 3/3] docs: update README content for CLI changes --- README.md | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 291998c03..632c5f829 100644 --- a/README.md +++ b/README.md @@ -44,24 +44,22 @@ Maven: The library can be consumed in JVM and ScalaJS projects, however the features available to each differ. See the JavaDoc for more details on the API. -The jar is also executable without a client: +The jar is also executable without a client via the commands, `CheckForIssues` and `DependencyReport`: ```sh # Assuming dep jars are in the same directory - java -cp "apex-ls*.jar" io.github.apexdevtools.apexls.Main [args] + java -cp "apex-ls*.jar" io.github.apexdevtools.apexls. [args] ``` -The chosen directory should contain an `sfdx-project.json`. The following arguments are available: - -| Argument | Description | -| --- | --- | -| `-json` | Write output as JSON. Logging is suppressed. | -| `-verbose` | Include warnings in log output. | -| `-info` / `-debug` | Change log level from default. | -| `-nocache` | Do not load from or write to an existing apex-ls cache. | -| `-unused` | Display unused value warnings. (Requires `-verbose`) | -| `-depends` | Display apex type dependencies as either csv or json if `-json` is set. | -| `-outlinesingle` / `-outlinemulti` | Use the apex outline parser in single or multi threaded mode. Otherwise uses default ANTLR parser. | +The following arguments are available: + +| Argument | Description | Supported Commands | +| --- | --- | --- | +| `--format` / `-f` | Output format: `text (default) \| json \| pmd` | All | +| `--logging` / `-l` | Text output logging level: `none (default) \| info \| debug` | All | +| `--nocache` / `-n` | Do not load from or write to an existing apex-ls cache. | All | +| `--workspace` / `-w` | Workspace directory path, defaults to current directory. Must contain an `sfdx-project.json` file. | All | +| `--detail` / `-d` | Detail level: `errors (default) \| warnings \| unused` | CheckForIssues | ## Development