diff --git a/.github/workflows/Build.yml b/.github/workflows/Build.yml index 148c77e..97666dd 100644 --- a/.github/workflows/Build.yml +++ b/.github/workflows/Build.yml @@ -17,12 +17,14 @@ jobs: - uses: actions/setup-java@v4 with: distribution: 'temurin' - java-version: 8 + java-version: 17 cache: 'sbt' + - uses: sbt/setup-sbt@v1 + - uses: actions/setup-node@v4 with: - node-version: 16 + node-version: 22 - name: FormatCheck run: sbt scalafmtCheck # Does not check test files currently @@ -31,12 +33,12 @@ jobs: run: sbt build - name: Checkout samples repo - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: submodules: recursive repository: apex-dev-tools/apex-samples path: apex-samples - ref: v1.0.2 + ref: v1.4.0 - name: Set samples env run: echo "SAMPLES=$GITHUB_WORKSPACE/apex-samples" >> "$GITHUB_ENV" diff --git a/README.md b/README.md index dc755b0..c89db1b 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This parser extracts an outline from Apex class files. The outline provides structural information about the class and its inner classes without needing to parse code blocks. The performance of this parser is much better than a full parser, making it ideal for use when indexing or similar activities. +This library includes the Apex type definitions (interfaces and base types) that were previously published separately as `apex-types`. Starting with version 2.0.0, these are bundled together to simplify dependency management. + If you need access to a full syntax tree for Apex, SOQL or SOSL we recommend using the [apex-parser](https://github.com/apex-dev-tools/apex-parser) instead. ## Getting Started diff --git a/build.sbt b/build.sbt index a44d512..32095cb 100644 --- a/build.sbt +++ b/build.sbt @@ -3,7 +3,7 @@ import org.scalajs.linker.interface.Report import scala.sys.process._ ThisBuild / scalaVersion := "2.13.10" -ThisBuild / description := "Salesforce Apex outline parser" +ThisBuild / description := "Salesforce Apex outline parser with type definitions" ThisBuild / organization := "io.github.apex-dev-tools" ThisBuild / organizationHomepage := Some(url("https://github.com/apex-dev-tools/outline-parser")) ThisBuild / homepage := Some(url("https://github.com/apex-dev-tools/outline-parser")) @@ -42,7 +42,6 @@ lazy val parser = crossProject(JSPlatform, JVMPlatform) name := "outline-parser", scalacOptions += "-deprecation", libraryDependencies ++= Seq( - "io.github.apex-dev-tools" %%% "apex-types" % "1.3.0", "org.scalatest" %%% "scalatest" % "3.2.9" % Test, "io.github.apex-dev-tools" %%% "apex-ls" % "4.3.1" % Test ) diff --git a/js/src/main/scala/io/github/apexdevtools/api/Issue.scala b/js/src/main/scala/io/github/apexdevtools/api/Issue.scala new file mode 100644 index 0000000..5f7f5ef --- /dev/null +++ b/js/src/main/scala/io/github/apexdevtools/api/Issue.scala @@ -0,0 +1,47 @@ +/* + Copyright (c) 2021 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.api + +/* WARNING: This must be identical to Java class of same name */ +trait Issue { + + /* The generator of this issue, default to APEX_LS_PROVIDER */ + def provider(): String + + /* The file path where the issue was found */ + def filePath(): String + + /* The location within the file */ + def fileLocation(): IssueLocation + + /* The rule violated, sets the issue priority */ + def rule(): Rule + + /* The issue message */ + def message(): String + + /* Is this considered an error issue, rather than a warning */ + def isError: java.lang.Boolean + + /* Format as String, filePath is omitted to avoid duplicating over multiple Issues */ + def asString: String = rule().name() + ": " + fileLocation().displayPosition + ": " + message() + + override def toString: String = filePath() + ": " + asString +} + +object Issue { + // Default source name for apex-ls generated diagnostics + final val APEX_LS_PROVIDER = "apex-ls" +} diff --git a/js/src/main/scala/io/github/apexdevtools/api/IssueLocation.scala b/js/src/main/scala/io/github/apexdevtools/api/IssueLocation.scala new file mode 100644 index 0000000..611311e --- /dev/null +++ b/js/src/main/scala/io/github/apexdevtools/api/IssueLocation.scala @@ -0,0 +1,54 @@ +/* + Copyright (c) 2021 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.api + +/* WARNING: This must be identical to Java class of same name */ +trait IssueLocation { + def startLineNumber(): Int + def startCharOffset(): Int + def endLineNumber(): Int + def endCharOffset(): Int + + def displayPosition: String = { + if ( + startLineNumber() == 1 && endLineNumber() == Int.MaxValue && startCharOffset() == 0 && endCharOffset() == 0 + ) { + s"line 1" + } else if (startLineNumber() == endLineNumber()) { + if (startCharOffset() == 0 && endCharOffset() == 0) + s"line ${startLineNumber()}" + else if (startCharOffset() == endCharOffset()) + s"line ${startLineNumber()} at ${startCharOffset()}" + else + s"line ${startLineNumber()} at ${startCharOffset()}-${endCharOffset()}" + } else { + if (startCharOffset() == 0 && endCharOffset() == 0) + s"line ${startLineNumber()} to ${endLineNumber()}" + else + s"line ${startLineNumber()}:${startCharOffset()} to ${endLineNumber()}:${endCharOffset()}" + } + } + + def contains(line: Int, offset: Int): Boolean = { + !(line < startLineNumber() || line > endLineNumber() || + (line == startLineNumber() && offset < startCharOffset()) || + (line == endLineNumber() && offset > endCharOffset())) + } + + def contains(other: IssueLocation): Boolean = { + contains(other.startLineNumber(), other.startCharOffset()) && + contains(other.endLineNumber(), other.endCharOffset()) + } +} diff --git a/js/src/main/scala/io/github/apexdevtools/api/Rule.scala b/js/src/main/scala/io/github/apexdevtools/api/Rule.scala new file mode 100644 index 0000000..9b33b2c --- /dev/null +++ b/js/src/main/scala/io/github/apexdevtools/api/Rule.scala @@ -0,0 +1,34 @@ +/* + Copyright (c) 2021 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.api + +/* WARNING: This must be identical to Java class of same name */ +trait Rule { + /* The nane of the rule */ + def name(): String + + /* Range 1-5, 1 being the highest */ + def priority(): Integer +} + +object Rule { + // Priority constants, these should become enums after moving to Scala3 + // These are based on SonarQube, PMD uses Integer 1-5 + final val BLOCKER_PRIORITY = 1 // Change absolutely required + final val CRITICAL_PRIORITY = 2 // Change highly recommended + final val MAJOR_PRIORITY = 3 // Change recommended + final val MINOR_PRIORITY = 4 // Change optional + final val INFO_PRIORITY = 5 // Change highly optional +} diff --git a/jvm/src/main/java/io/github/apexdevtools/api/Issue.java b/jvm/src/main/java/io/github/apexdevtools/api/Issue.java new file mode 100644 index 0000000..39164b8 --- /dev/null +++ b/jvm/src/main/java/io/github/apexdevtools/api/Issue.java @@ -0,0 +1,67 @@ +/* + Copyright (c) 2021 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.api; + +/** + * A diagnostic issue. + * Each issue must identify which provider generated it, the file path & location it refers to, + * a Rule the issue relates to, if the issue should be considered an error and message for + * the issue. This is based on the PMD model of a diagnostic issue. + * WARNING: This must be identical to Scala class of same name + */ +public abstract class Issue { + + /** + * Name of provider which created this Issue + */ + public abstract String provider(); + + /** + * The file path where the issue was found + */ + public abstract String filePath(); + + /** + * The location within the file + */ + public abstract IssueLocation fileLocation(); + + /** + * The rule violated, sets the issue priority + */ + public abstract Rule rule(); + + /** + * Is this considered an error issue, rather than a warning + */ + public abstract Boolean isError(); + + /** + * The issue message + */ + public abstract String message(); + + /** + * Format as String, filePath is omitted to avoid duplicating over multiple Issues + */ + public String asString() { + return rule().name() + ": " + fileLocation().displayPosition() + ": " + message(); + } + + @Override + public String toString() { + return filePath() + ": " + asString(); + } +} diff --git a/jvm/src/main/java/io/github/apexdevtools/api/IssueLocation.java b/jvm/src/main/java/io/github/apexdevtools/api/IssueLocation.java new file mode 100644 index 0000000..63657fc --- /dev/null +++ b/jvm/src/main/java/io/github/apexdevtools/api/IssueLocation.java @@ -0,0 +1,58 @@ +/* + Copyright (c) 2021 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.api; + +/** + * Location of an Issue in a file. + * Line numbers start at 1, character offsets within a line start at 0. + * Assumes start position <= end position but does not validate this. + * WARNING: This must be identical to Scala class of same name + */ +public abstract class IssueLocation { + public abstract int startLineNumber(); + public abstract int startCharOffset(); + public abstract int endLineNumber(); + public abstract int endCharOffset(); + + public String displayPosition() { + if (startLineNumber() == 1 && endLineNumber() == Integer.MAX_VALUE && startCharOffset() == 0 && endCharOffset() == 0) { + return "line 1"; + } + if (startLineNumber() == endLineNumber()) { + if (startCharOffset() == 0 && endCharOffset() == 0) + return "line " + startLineNumber(); + else if (startCharOffset() == endCharOffset()) + return "line " + startLineNumber() + " at " + startCharOffset(); + else + return "line " + startLineNumber() + " at " + startCharOffset() + "-" + endCharOffset(); + } else { + if (startCharOffset() == 0 && endCharOffset() == 0) + return "line " + startLineNumber() + " to " + endLineNumber(); + else + return "line " + startLineNumber() + ":" + startCharOffset() + " to " + endLineNumber() + ":" + endCharOffset(); + } + } + + public boolean contains(int line, int offset) { + return !(line < startLineNumber() || line > endLineNumber() || + (line == startLineNumber() && offset < startCharOffset()) || + (line == endLineNumber() && offset > endCharOffset())); + } + + public boolean contains(IssueLocation other) { + return contains(other.startLineNumber(), other.startCharOffset()) && + contains(other.endLineNumber(), other.endCharOffset()); + } +} diff --git a/jvm/src/main/java/io/github/apexdevtools/api/Rule.java b/jvm/src/main/java/io/github/apexdevtools/api/Rule.java new file mode 100644 index 0000000..0c9d312 --- /dev/null +++ b/jvm/src/main/java/io/github/apexdevtools/api/Rule.java @@ -0,0 +1,55 @@ +/* + Copyright (c) 2021 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.api; + +/** + * Analysis named rule. + * Provides basic information about the Rule. + * WARNING: This must be identical to Scala class of same name + */ +public interface Rule { + // Priority constants, these are based on SonarQube, PMD uses Integer 1-5 + + /** + * Change absolutely required + */ + Integer BLOCKER_PRIORITY = 1; + /** + * Change highly recommended + */ + Integer CRITICAL_PRIORITY = 2; + /** + * Change recommended + */ + Integer MAJOR_PRIORITY = 3; + /** + * Change optional + */ + Integer MINOR_PRIORITY = 4; + /** + * Change highly optional + */ + Integer INFO_PRIORITY = 5; + + /** + * The nane of the rule, does need to be unique but recommended it is + */ + String name(); + + /** + * Priority Range 1-5, 1 being the highest + */ + Integer priority(); +} diff --git a/jvm/src/main/java/io/github/apexdevtools/spi/AnalysisProvider.java b/jvm/src/main/java/io/github/apexdevtools/spi/AnalysisProvider.java new file mode 100644 index 0000000..f81381e --- /dev/null +++ b/jvm/src/main/java/io/github/apexdevtools/spi/AnalysisProvider.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 FinancialForce.com, inc. All rights reserved. + */ +package io.github.apexdevtools.spi; + +import io.github.apexdevtools.api.Issue; + +import java.nio.file.Path; +import java.util.List; + +/** + * Service provider for an external analysis that can return Issues. + * The Provider may throw on bad input. + */ +public interface AnalysisProvider { + + /** + * Return an identifier for the provider, these need to be unique across all providers. + */ + String getProviderId(); + + /** + * Set provider configuration items, these need to fit a key->value(s) model. + * + * @param name name of configuration parameter + * @param values optional list of values to use with parameter + */ + void setConfiguration(String name, List values); + + /** + * Test if configured correctly for use in workspace + * + * @param workspacePath workspace to run analysis in + */ + Boolean isConfigured(Path workspacePath); + + /** + * Return issues for the set of files. + * + * @param workspacePath workspace to run analysis in + * @param files files within workspace to analyse + */ + Issue[] collectIssues(Path workspacePath, Path[] files); +} diff --git a/shared/src/main/scala/com/financialforce/types/IBodyDeclaration.scala b/shared/src/main/scala/com/financialforce/types/IBodyDeclaration.scala new file mode 100644 index 0000000..9fba0c0 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IBodyDeclaration.scala @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base.{IdWithLocation, Location} + +/** Common handling for elements that can appear in a class body. bodyLocation define the location + * of the element while blockLocation defines the location of any nested block which will be a + * subpart of the bodyLocation. + */ +trait IBodyDeclaration { + def id: IdWithLocation + def bodyLocation: Option[Location] + def blockLocation: Option[Location] +} diff --git a/shared/src/main/scala/com/financialforce/types/IConstructorDeclaration.scala b/shared/src/main/scala/com/financialforce/types/IConstructorDeclaration.scala new file mode 100644 index 0000000..e67a21d --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IConstructorDeclaration.scala @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base._ + +import scala.collection.immutable.ArraySeq +import scala.util.hashing.MurmurHash3 + +/** Class constructor, note custom equality does not include location information. */ +trait IConstructorDeclaration extends IBodyDeclaration with AnnotationsAndModifiers { + def qname: QualifiedName + def formalParameters: ArraySeq[IFormalParameter] + override def id: IdWithLocation + override def bodyLocation: Option[Location] + override def blockLocation: Option[Location] + override def annotations: Array[Annotation] + override def modifiers: Array[Modifier] + + def signature: String = + s"$annotationsAndModifiers $qname(${formalParameters.mkString(", ")})".tidyWhitespace + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[IConstructorDeclaration] + (other.annotations sameElements annotations) && + (other.modifiers sameElements modifiers) && + other.id == id && + other.formalParameters == formalParameters + } + + override def hashCode(): Int = { + MurmurHash3.orderedHash( + Seq( + MurmurHash3.arrayHash(annotations), + MurmurHash3.arrayHash(modifiers), + id, + formalParameters + ) + ) + } + + override def toString: String = { + s"${id.location} $signature".tidyWhitespace + } +} diff --git a/shared/src/main/scala/com/financialforce/types/IFieldDeclaration.scala b/shared/src/main/scala/com/financialforce/types/IFieldDeclaration.scala new file mode 100644 index 0000000..aa023c2 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IFieldDeclaration.scala @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base._ + +/** Class field, note custom IVariable equality does not include location information or property + * blocks. + */ +trait IFieldDeclaration extends IBodyDeclaration with IVariable { + override var typeRef: TypeRef + override def id: IdWithLocation + override def bodyLocation: Option[Location] + override def blockLocation: Option[Location] + override def annotations: Array[Annotation] + override def modifiers: Array[Modifier] + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[IFieldDeclaration] + super.equals(other) + } +} diff --git a/shared/src/main/scala/com/financialforce/types/IFormalParameter.scala b/shared/src/main/scala/com/financialforce/types/IFormalParameter.scala new file mode 100644 index 0000000..071a607 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IFormalParameter.scala @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base._ + +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ + +/** Method formal parameter, tightly aligned to IVariable. */ +trait IFormalParameter extends IVariable with IdWithLocation { + override def location: Location + override def annotations: Array[Annotation] + override def modifiers: Array[Modifier] + override var typeRef: TypeRef + override def name: String + override def id = new LocatableId(name, location) + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[IFormalParameter] + super.equals(other) + } +} diff --git a/shared/src/main/scala/com/financialforce/types/IInitializer.scala b/shared/src/main/scala/com/financialforce/types/IInitializer.scala new file mode 100644 index 0000000..17d7fe8 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IInitializer.scala @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base.{IdWithLocation, Location} + +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ + +/** Static or non-static initializer block */ +trait IInitializer extends IBodyDeclaration { + def isStatic: Boolean + override def id: IdWithLocation + override def bodyLocation: Option[Location] + override def blockLocation: Option[Location] +} diff --git a/shared/src/main/scala/com/financialforce/types/IMethodDeclaration.scala b/shared/src/main/scala/com/financialforce/types/IMethodDeclaration.scala new file mode 100644 index 0000000..e2292d3 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IMethodDeclaration.scala @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base._ + +import scala.collection.immutable.ArraySeq +import scala.util.hashing.MurmurHash3 + +/** Class method, note custom equality does not include location information. */ +trait IMethodDeclaration extends IBodyDeclaration with AnnotationsAndModifiers { + var typeRef: Option[TypeRef] + def formalParameters: ArraySeq[IFormalParameter] + override def id: IdWithLocation + override def bodyLocation: Option[Location] + override def blockLocation: Option[Location] + override def annotations: Array[Annotation] + override def modifiers: Array[Modifier] + + def signature: String = { + val typeName = typeRef.map(_.toString).getOrElse("void") + s"$annotationsAndModifiers $typeName $id(${formalParameters.mkString(", ")})".tidyWhitespace + } + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[IMethodDeclaration] + (other.annotations sameElements annotations) && + (other.modifiers sameElements modifiers) && ( + (other.typeRef.isEmpty && typeRef.isEmpty) || + (other.typeRef.nonEmpty && typeRef.nonEmpty && + other.typeRef.get.sameRef(typeRef.get)) + ) && + other.id == id && + other.formalParameters == formalParameters + } + + override def hashCode(): Int = { + MurmurHash3.orderedHash( + Seq( + MurmurHash3.arrayHash(annotations), + MurmurHash3.arrayHash(modifiers), + typeRef.map(_.toString.toLowerCase().hashCode).getOrElse(0), + id, + formalParameters.hashCode() + ) + ) + } + + override def toString: String = { + s"${id.location} $signature" + } +} diff --git a/shared/src/main/scala/com/financialforce/types/IPropertyDeclaration.scala b/shared/src/main/scala/com/financialforce/types/IPropertyDeclaration.scala new file mode 100644 index 0000000..8aaa68e --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IPropertyDeclaration.scala @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base._ + +/** Class property, note custom IVariable equality does not include location information or property + * blocks. + */ +trait IPropertyDeclaration extends IBodyDeclaration with IVariable { + def propertyBlocks: Array[PropertyBlock] + override def typeRef: TypeRef + override def id: IdWithLocation + override def bodyLocation: Option[Location] + override def blockLocation: Option[Location] + override def annotations: Array[Annotation] + override def modifiers: Array[Modifier] + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[IPropertyDeclaration] + super.equals(other) + } +} diff --git a/shared/src/main/scala/com/financialforce/types/ITypeDeclaration.scala b/shared/src/main/scala/com/financialforce/types/ITypeDeclaration.scala new file mode 100644 index 0000000..396aa42 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/ITypeDeclaration.scala @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base._ + +import scala.collection.immutable.ArraySeq + +/** Class, enum, interface type declaration. May be used as a resolved TypeRef. Uses Identity + * equality. + */ +trait ITypeDeclaration extends TypeRef with AnnotationsAndModifiers { + def paths: Array[String] + def location: Location + + def id: IdWithLocation + + def typeNameSegment: TypeNameSegment + + def enclosing: Option[ITypeDeclaration] + def extendsTypeRef: TypeRef + def implementsTypeList: ArraySeq[TypeRef] + override def modifiers: Array[Modifier] + override def annotations: Array[Annotation] + + def initializers: ArraySeq[IInitializer] + def innerTypes: ArraySeq[ITypeDeclaration] + def constructors: ArraySeq[IConstructorDeclaration] + def methods: ArraySeq[IMethodDeclaration] + def properties: ArraySeq[IPropertyDeclaration] + def fields: ArraySeq[IFieldDeclaration] + + def typeName: Array[TypeNameSegment] = { + enclosing match { + case Some(enc) => Array(enc.typeNameSegment, typeNameSegment) + case None => Array(typeNameSegment) + } + } + + override def fullName: String = { + enclosing.map(_.fullName + ".").getOrElse("") + typeNameSegment.toString + } + + override def toString: String = fullName + + override def equals(that: Any): Boolean = { + that match { + case other: ITypeDeclaration => other.eq(this) + case _ => false + } + } + + override def hashCode(): Int = System.identityHashCode(this) +} diff --git a/shared/src/main/scala/com/financialforce/types/IVariable.scala b/shared/src/main/scala/com/financialforce/types/IVariable.scala new file mode 100644 index 0000000..48cc2b9 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/IVariable.scala @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types + +import com.financialforce.types.base._ + +import scala.util.hashing.MurmurHash3 + +/** Common handling for Variable like elements (fields, properties and formal parameters) that have + * similar members. Note: custom equality does not include location information. + */ +trait IVariable extends AnnotationsAndModifiers { + var typeRef: TypeRef + def id: IdWithLocation + override def annotations: Array[Annotation] + override def modifiers: Array[Modifier] + + def signature: String = + s"$annotationsAndModifiers $typeRef $id".tidyWhitespace + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[IVariable] + + (other.annotations sameElements annotations) && + (other.modifiers sameElements modifiers) && + other.typeRef.sameRef(typeRef) && + other.id == id + } + + override def hashCode(): Int = { + MurmurHash3.orderedHash( + Seq(MurmurHash3.arrayHash(annotations), MurmurHash3.arrayHash(modifiers), typeRef, id) + ) + } + + override def toString: String = signature +} diff --git a/shared/src/main/scala/com/financialforce/types/base/Annotation.scala b/shared/src/main/scala/com/financialforce/types/base/Annotation.scala new file mode 100644 index 0000000..11ce25b --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/Annotation.scala @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +import com.financialforce.types.ArrayInternCache + +import scala.util.hashing.MurmurHash3 + +/** Annotation element, name is case-insensitive, parameters are unparsed. */ +case class Annotation(name: String, parameters: Option[String]) { + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[Annotation] + name.equalsIgnoreCase(other.name) && + parameters.getOrElse("").equalsIgnoreCase(other.parameters.getOrElse("")) + } + + override def hashCode(): Int = { + MurmurHash3.orderedHash(Seq(name.toLowerCase(), parameters.getOrElse(""))) + } + + override def toString: String = { + if (parameters.isDefined) s"@$name(${parameters.get})" else s"@$name" + } +} + +/** Caching support for Arrays of annotations. */ +object Annotation { + final val emptyArray = Array[Annotation]() + + private val cache = new ArrayInternCache[Annotation]() + + def intern(annotations: Array[Annotation]): Array[Annotation] = { + cache.intern(annotations) + } +} diff --git a/shared/src/main/scala/com/financialforce/types/base/AnnotationsAndModifiers.scala b/shared/src/main/scala/com/financialforce/types/base/AnnotationsAndModifiers.scala new file mode 100644 index 0000000..9dfd248 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/AnnotationsAndModifiers.scala @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +/** Helper for elements supporting annotations & modifiers */ +trait AnnotationsAndModifiers { + def annotations: Array[Annotation] + def modifiers: Array[Modifier] + + def annotationsAndModifiers: String = (annotations ++ modifiers).mkString(" ") +} diff --git a/shared/src/main/scala/com/financialforce/types/base/Id.scala b/shared/src/main/scala/com/financialforce/types/base/Id.scala new file mode 100644 index 0000000..1bd4e0a --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/Id.scala @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +/** Identifier, always treat as case insensitive & ideally intern the name. */ +trait Id { + def name: String + + def lowerCaseName: String = name.toLowerCase +} + +/** Identifier with a location in a file from where it was extracted. Location does not hold path + * information so that needs to be available from some other context, such as the ITypeDeclaration + * that this is found in. + */ +trait IdWithLocation extends Id { + def location: Location +} + +/** Helper for implementing IdWithLocation support into a class in a memory friendly way. */ +abstract class IdLocationHolder(_location: Location) extends IdWithLocation { + // These are inlined to save memory + private val startLine: Int = _location.startLine + private val startLineOffset: Int = _location.startLineOffset + private val startByteOffset: Int = _location.startByteOffset + private val endLine: Int = _location.endLine + private val endLineOffset: Int = _location.endLineOffset + private val endByteOffset: Int = _location.endByteOffset + + override def location: Location = + Location(startLine, startLineOffset, startByteOffset, endLine, endLineOffset, endByteOffset) +} + +/** An Id and its associated location, beware equality is defined only over the id. */ +class LocatableId(override val name: String, _location: Location) + extends IdLocationHolder(_location) { + + override def toString: String = name + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[LocatableId] + lowerCaseName.equals(other.lowerCaseName) + } + + override val hashCode: Int = lowerCaseName.hashCode +} diff --git a/shared/src/main/scala/com/financialforce/types/base/Location.scala b/shared/src/main/scala/com/financialforce/types/base/Location.scala new file mode 100644 index 0000000..c48d21b --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/Location.scala @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +/** Position in a source file, uses line/lineOffset & byteOffset to save recomputing later. */ +case class Position(line: Int, lineOffset: Int, byteOffset: Int) { + override def toString: String = { + s"[$line.$lineOffset;$byteOffset" + } +} + +/** Location of a range in a source file, logically a start and end position but unrolled to reduce + * object overhead. Note: We don't enforce constraints such as end>=start so consumers should take + * care, see also Location.default. + */ +case class Location( + startLine: Int, + startLineOffset: Int, + startByteOffset: Int, + endLine: Int, + endLineOffset: Int, + endByteOffset: Int +) { + def startPosition: Position = Position(startLine, startLineOffset, startByteOffset) + def endPosition: Position = Position(endLine, endLineOffset, endByteOffset) + + override def toString: String = { + s"[$startLine.$startLineOffset->$endLine.$endLineOffset;$startByteOffset->$endByteOffset]" + } +} + +object Location { + /* A default location, maps to start of the file but has zero characters. */ + val default: Location = Location(0, 0, 0, 0, 0, 0) + + def apply(start: Position, end: Position): Location = { + Location( + start.line, + start.lineOffset, + start.byteOffset, + end.line, + end.lineOffset, + end.byteOffset + ) + } + + def span(sl: Location, el: Location): Location = { + Location( + sl.startLine, + sl.startLineOffset, + sl.startByteOffset, + el.endLine, + el.endLineOffset, + el.endByteOffset + ) + } +} diff --git a/shared/src/main/scala/com/financialforce/types/base/Modifier.scala b/shared/src/main/scala/com/financialforce/types/base/Modifier.scala new file mode 100644 index 0000000..ef508ce --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/Modifier.scala @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +import com.financialforce.types.ArrayInternCache + +/** Modifier element, text is case-insensitive. */ +case class Modifier(text: String) { + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[Modifier] + text.equalsIgnoreCase(other.text) + } + + override def hashCode(): Int = { + text.toLowerCase.hashCode + } + + override def toString: String = text +} + +/** Caching support for Arrays of modifiers. */ +object Modifier { + final val emptyArray = Array[Modifier]() + + private val cache = new ArrayInternCache[Modifier]() + + def intern(modifiers: Array[Modifier]): Array[Modifier] = { + cache.intern(modifiers) + } +} diff --git a/shared/src/main/scala/com/financialforce/types/base/PropertyBlock.scala b/shared/src/main/scala/com/financialforce/types/base/PropertyBlock.scala new file mode 100644 index 0000000..e20beb5 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/PropertyBlock.scala @@ -0,0 +1,7 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +/** Get/Set property block */ +case class PropertyBlock(location: Location) diff --git a/shared/src/main/scala/com/financialforce/types/base/QualifiedName.scala b/shared/src/main/scala/com/financialforce/types/base/QualifiedName.scala new file mode 100644 index 0000000..86ff1e8 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/QualifiedName.scala @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +/** Dot seperated name without type arguments. */ +case class QualifiedName(parts: Array[IdWithLocation]) { + def location: Location = { + val start = parts.head.location + val end = parts.last.location + Location.span(start, end) + } + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[QualifiedName] + parts.sameElements(other.parts) + } + + override def toString: String = { + parts.map(_.toString).mkString(".") + } +} diff --git a/shared/src/main/scala/com/financialforce/types/base/TypeRef.scala b/shared/src/main/scala/com/financialforce/types/base/TypeRef.scala new file mode 100644 index 0000000..78474c0 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/base/TypeRef.scala @@ -0,0 +1,167 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce.types.base + +import scala.collection.immutable.ArraySeq +import scala.collection.mutable.ArrayBuffer +import scala.util.hashing.MurmurHash3 + +/** Reference holder to a type. Typically TypeRefs start as a UnresolvedTypeRef and are replaced by + * a ITypeDeclaration during a resolve phase. The fullName can be used to determine equivalence + * between these two types of TypeRef. + */ +trait TypeRef { + def fullName: String + + def sameRef(other: TypeRef): Boolean = { + other.fullName.equalsIgnoreCase(fullName) + } +} + +object TypeRef { + final val emptyArraySeq: ArraySeq[TypeRef] = ArraySeq() + + def toTypeRefs(params: Array[String]): ArraySeq[TypeRef] = { + ArraySeq.unsafeWrapArray(params.map(tp => { + UnresolvedTypeRef( + Array(new TypeNameSegment(new LocatableId(tp, Location.default), emptyArraySeq)), + 0 + ) + })) + } +} + +/** A reference to a type in raw form as it appears in source files. This just captures the segments + * in the TypeRef and any array subscripts. We expect a resolve process to use this information to + * locate an ITypeDeclaration. + */ +final case class UnresolvedTypeRef(typeNameSegments: Array[TypeNameSegment], arraySubscripts: Int) + extends TypeRef { + + override def fullName: String = { + typeNameSegments.mkString(".") + ("[]" * arraySubscripts) + } + + override def equals(obj: Any): Boolean = { + val other = obj.asInstanceOf[UnresolvedTypeRef] + typeNameSegments.sameElements(other.typeNameSegments) && + arraySubscripts == other.arraySubscripts + } + + override def hashCode(): Int = { + MurmurHash3.orderedHash(Seq(arraySubscripts, MurmurHash3.arrayHash(typeNameSegments))) + } + + override def toString: String = fullName +} + +object UnresolvedTypeRef { + + /** Convert a string into a UnresolvedTypeRef. Note: This is really only intended for internal use + * as the error handling is limited. + */ + def apply(typeName: String): Either[String, UnresolvedTypeRef] = { + + // Strip trailing array subscripts + var remaining: String = typeName.trim.replaceAll("\\s", "") + var arraySubscripts = 0 + while (remaining.endsWith("[]")) { + arraySubscripts += 1 + remaining = remaining.substring(0, remaining.length - 2) + } + + val parts = safeSplit(remaining, '.') + val segments: List[Either[String, TypeNameSegment]] = parts.map(part => { + // Handle segment type arguments + if (part.contains('<')) { + val argSplit = part.split("<", 2) + if (argSplit.length != 2 || argSplit(1).length < 2 || remaining.last != '>') + Left(s"Unmatched '<' found in '$part'") + else { + buildTypeNames(argSplit(1).take(argSplit(1).length - 1)) match { + case Right(argTypes) => + Right(TypeNameSegment(argSplit.head, argTypes)) + case Left(err) => + Left(err) + } + } + } else { + Right(TypeNameSegment(part)) + } + }) + + val errors = segments.collect { case Left(error) => error } + if (errors.nonEmpty) + return Left(errors.head) + Right(apply(segments.collect { case Right(segment) => segment }.toArray, arraySubscripts)) + } + + /** Split a list of comma delimited type names */ + private def buildTypeNames(value: String): Either[String, Array[UnresolvedTypeRef]] = { + val args = + safeSplit(value, ',').foldLeft(ArrayBuffer[Either[String, UnresolvedTypeRef]]())( + (acc, arg) => { + acc.append(apply(arg)) + acc + } + ) + + args + .collectFirst { case Left(err) => err } + .map(err => Left(err)) + .getOrElse(Right(args.collect { case Right(tn) => tn }.toArray)) + } + + /** Split a string at 'separator' but ignoring if within '<...>' blocks. */ + private def safeSplit(value: String, separator: Char): List[String] = { + var parts: List[String] = Nil + var current = new StringBuffer() + var depth = 0 + value.foreach { + case '<' => depth += 1; current.append('<') + case '>' => depth -= 1; current.append('>') + case x if x == separator && depth == 0 => + parts = current.toString :: parts; current = new StringBuffer() + case x => current.append(x) + } + parts = current.toString :: parts + parts.reverse + } +} + +/** A single segment of a TypeRef, essentially and id with optional type arguments */ +final case class TypeNameSegment(id: IdWithLocation, typeArguments: ArraySeq[TypeRef]) { + + def replaceArguments(args: ArraySeq[TypeRef]): TypeNameSegment = { + TypeNameSegment(id, args) + } + + override def toString: String = { + if (typeArguments.nonEmpty) + s"$id<${typeArguments.map(_.fullName).mkString(",")}>" + else + id.toString + } +} + +object TypeNameSegment { + def apply(name: String): TypeNameSegment = { + new TypeNameSegment(new LocatableId(name, Location.default), TypeRef.emptyArraySeq) + } + + def apply(name: String, typeArguments: ArraySeq[TypeRef]): TypeNameSegment = { + new TypeNameSegment(new LocatableId(name, Location.default), typeArguments) + } + + def apply(name: String, typeArguments: Array[UnresolvedTypeRef]): TypeNameSegment = { + new TypeNameSegment( + new LocatableId(name, Location.default), + ArraySeq.unsafeWrapArray(typeArguments) + ) + } + + def apply(name: String, params: Array[String]): TypeNameSegment = { + new TypeNameSegment(new LocatableId(name, Location.default), TypeRef.toTypeRefs(params)) + } +} diff --git a/shared/src/main/scala/com/financialforce/types/package.scala b/shared/src/main/scala/com/financialforce/types/package.scala new file mode 100644 index 0000000..0f88d02 --- /dev/null +++ b/shared/src/main/scala/com/financialforce/types/package.scala @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2021 FinancialForce.com, inc. All rights reserved. + */ +package com.financialforce + +import scala.collection.immutable.ArraySeq +import scala.collection.mutable + +package object types { + + private[types] implicit class StringOps(value: String) { + /* Improve a formatted string by removing leading/trailing & duplicate whitespaces. */ + def tidyWhitespace: String = value.trim.replaceAll(" +", " ") + } + + /** Simple cache for interning Arrays of values. This needs special handling as Array uses + * reference equality. + */ + private[types] class ArrayInternCache[T] { + private var cache = mutable.HashMap[ArraySeq[T], Array[T]]() + + def intern(value: Array[T]): Array[T] = { + val wrapped = ArraySeq.unsafeWrapArray(value) + cache.getOrElseUpdate(wrapped, value) + } + + def clean(): Unit = { + cache = new mutable.HashMap() + } + } +} diff --git a/shared/src/test/scala/com/financialforce/oparser/SmokeTest.scala b/shared/src/test/scala/com/financialforce/oparser/SmokeTest.scala index e1353e1..f4af536 100644 --- a/shared/src/test/scala/com/financialforce/oparser/SmokeTest.scala +++ b/shared/src/test/scala/com/financialforce/oparser/SmokeTest.scala @@ -180,7 +180,6 @@ class SmokeTest extends AnyFunSpec { assert(thrown.getMessage == "Unrecognised method [2.3 -> 2.16] public Dummy ( )") } - it("errors on constructor without body terminated by class end") { val content = """public class Dummy {