8000 Emit warnings on unused variables by rpiaggio · Pull Request #853 · gemini-hlsw/clue · GitHub
[go: up one dir, main page]

Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8000
116 changes: 42 additions & 74 deletions core/src/main/scala/clue/GraphQLInterpolator.scala
Original file line number Diff line number Diff line change
Expand Up @@ -112,62 +112,8 @@ private[clue] object GraphQLInterpolator {
loop(Symbol.spliceOwner)
}

def splitTopLevel(s: String): List[String] = {
val out = scala.collection.mutable.ListBuffer.empty[String]
val cur = new StringBuilder
var depth = 0
s.foreach {
case '[' => depth += 1; cur += '['
case ']' => depth -= 1; cur += ']'
case ',' if depth == 0 => out += cur.toString; cur.clear()
case c => cur += c
}
if (cur.nonEmpty) out += cur.toString
out.toList
}

// Parse a parenthesized var-def list `($a: T, $b: U)` into name -> GraphQL type.
def parseVarDefs(s0: String): Map[String, String] = {
val s = s0.trim.stripPrefix("(").stripSuffix(")").trim.replace("$$", "$")
if (s.isEmpty) Map.empty
else
splitTopLevel(s).flatMap { entry =>
val e = entry.trim
val nameEnd = e.indexOf(':')
if (nameEnd < 0 || !e.startsWith("$")) None
else {
val name = e.substring(1, nameEnd).trim
val tpe = e.substring(nameEnd + 1).takeWhile(_ != '=').trim
Some(name -> tpe)
}
}.toMap
}

// Extract and parse the operation header `(...)` from `query (...) ...` (the first literal part).
def operationVars(header: String): Map[String, String] = {
val h = header.replace("$$", "$")
val open = h.indexOf('(')
if (open < 0) Map.empty
else {
var depth = 0; var i = open; var end = -1
while (i < h.length && end < 0) {
h.charAt(i) match {
case '(' => depth += 1
case ')' => depth -= 1; if (depth == 0) end = i
case _ => ()
}
i += 1
}
if (end < 0) Map.empty else parseVarDefs(h.substring(open, end + 1))
}
}

// GraphQL "is variable usage allowed": the declared type must be usable where the required type
// is expected. Same base type, and a non-null requirement needs a non-null declared type.
def usableAs(declaredType: String, reqType: String): Boolean = {
val d = declaredType.trim; val r = reqType.trim
d.stripSuffix("!").trim == r.stripSuffix("!").trim && (!r.endsWith("!") || d.endsWith("!"))
}
val GraphQLText.Parsed(operationVars, bodyText) = GraphQLText.parse(parts)
import GraphQLText.{parseVarDefs, usableAs}

// What the splice site declares. An operation declares its variables in the document header; a
// subquery declares them in `type VariableDefs`. Both are read (they are never both populated in
Expand All @@ -178,34 +124,56 @@ private[clue] object GraphQLInterpolator {
.map(parseVarDefs)
.getOrElse(Map.empty)

val declaredVars: Map[String, String] =
enclosingVariableDefs ++ parts.headOption.map(operationVars).getOrElse(Map.empty)
val declaredVars: Map[String, String] = enclosingVariableDefs ++ operationVars

// Where the missing declaration has to be added. `name` keeps the module-class `$` suffix for an
// `object` subquery, which would only confuse the reader.
val declarationSite: String =
enclosingSubquery.fold("opera 8000 tion")(cls => s"subquery [${cls.name.stripSuffix("$")}]")

argExprs.foreach { arg =>
variableDefsOf(arg.asTerm.tpe).foreach { required =>
parseVarDefs(required).foreach { case (name, reqType) =>
declaredVars.get(name) match {
case None =>
report.errorAndAbort(
s"gql: $declarationSite does not declare variable $$$name required by a spliced subquery (needs $reqType)",
arg.asTerm.pos
)
case Some(declared) if !usableAs(declared, reqType) =>
report.errorAndAbort(
s"gql: $declarationSite declares $$$name: $declared but a spliced subquery requires it usable as $reqType",
arg.asTerm.pos
)
case _ => ()
}
// What each spliced subquery requires, by name.
val requiredBySplices: List[Map[String, String]] =
argExprs.map(arg =>
variableDefsOf(arg.asTerm.tpe).fold(Map.empty[String, String])(parseVarDefs)
)

argExprs.zip(requiredBySplices).foreach { case (arg, required) =>
required.foreach { case (name, reqType) =>
declaredVars.get(name) match {
case None =>
report.errorAndAbort(
s"gql: $declarationSite does not declare variable $$$name required by a spliced subquery (needs $reqType)",
arg.asTerm.pos
)
case Some(declared) if !usableAs(declared, reqType) =>
report.errorAndAbort(
s"gql: $declarationSite declares $$$name: $declared but a spliced subquery requires it usable as $reqType",
arg.asTerm.pos
)
case _ => ()
}
}
}

// Unused-declaration check (GraphQL "All Variables Used" / "Fragments Must Be Used"). A variable
// is used if the literal text references it or a spliced subquery requires it. Both are
// validation errors server-side; here they are warnings, since text scanning can't see past a
// splice and the server remains the authority. Fragment usage is not looked for inside splices
// because a spliced subquery may not spread the host's fragments (see `GraphQLSubquery`).
GraphQLText
.unusedVariables(declaredVars.keySet, bodyText, requiredBySplices.flatMap(_.keySet).toSet)
.foreach { name =>
report.warning(
s"gql: $declarationSite declares variable $$$name but never uses it",
Position.ofMacroExpansion
)
}
GraphQLText.unusedFragments(bodyText).foreach { name =>
report.warning(s"gql: 8000 fragment '$name' is defined but never spread",
Position.ofMacroExpansion
)
}
Comment on lines +171 to +175
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documenting.


// Runtime string, identical to `s"..."`, wrapped as a GraphQLDocument.
'{ GraphQLDocument.unsafeFromString($scExpr.s(${ Varargs(argExprs) }*)) }
}
Expand Down
4 changes: 4 additions & 0 deletions core/src/main/scala/clue/GraphQLSubquery.scala
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import io.circe.Decoder
* member (parenthesized var-defs, operation-header syntax). The member is not declared here: absent
* means the subquery references no variables. It is read by name — syntactically by the generator,
* and at compile time by the `gql` interpolator, which checks it against every splice site.
*
* A subquery is self-contained: it is validated standalone against its root type, so it cannot
* spread fragments defined by the document that splices it (nor define fragments itself). The
* `gql` unused-fragment warning relies on this: it only looks at the splicing document's own text.
Comment on lines +18 to +20
*/
abstract class GraphQLSubquery[S] extends GraphQLTextSyntax {
type Data
Expand Down
197 changes: 197 additions & 0 deletions core/src/main/scala/clue/GraphQLText.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
// Copyright (c) 2016-2026 Association of Universities for Research in Astronomy, Inc. (AURA)
// For license information see LICENSE or https://opensource.org/licenses/BSD-3-Clause

package clue

/**
* Text-level scanning of GraphQL documents, shared by the `gql` macro. Kept free of macro APIs so
* it can be unit-tested directly.
*
* This is deliberately not a GraphQL parser: `core` has no grackle dependency, and the macro only
* sees literal fragments with holes where subqueries are spliced. The scans are just precise enough
* for the variable and fragment checks, and only ever err on the side of silence: comments and
* string literals are blanked out before anything else runs (see `stripCommentsAndStrings`), so
* outside of them `fragment X on` is always a fragment definition and `$name` is always a variable
* reference.
*/
private[clue] object GraphQLText {

/**
* A document's literal parts split into the header's declarations and the body text. `body` has
* had its comments and string literals blanked out (see `stripCommentsAndStrings`).
*/
final case class Parsed(operationVars: Map[String, String], body: String)

/**
* Split the `gql` literal parts (as `StringContext.parts`, `$$`-escaped) into the operation
* header's variable declarations and the body text where usages and fragment spreads live. The
* header is excluded from the body so its `$name` declarations don't count as usages.
*/
def parse(parts: List[String]): Parsed = {
val strippedParts = parts.map(p => stripCommentsAndStrings(p.replace("$$", "$")))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

String literals are stripped per part, so a GraphQL string containing a splice leaves the closing " at the head of the next part, where it's read as an opening quote and blanks the remainder — losing real $var usages and spreads, producing false unused warnings (noise, not the documented "err on the side of silence").

val firstPart = strippedParts.headOption.getOrElse("")
val header = headerSpan(firstPart)

val operationVars = header.fold(Map.empty[String, String]) { case (open, end) =>
parseVarDefs(firstPart.substring(open, end + 1))
}
val body =
header.fold(firstPart) { case (_, end) => firstPart.substring(end + 1) } +
strippedParts.drop(1).mkString(" ")

Parsed(operationVars, body)
}

/**
* Blank out GraphQL comments (`#` to end of line) and string literals (`"..."` with `\"` escapes,
* and `"""..."""` block strings), replacing every character of them with a space so positions and
* line structure are preserved. Applied before any scan, so text inside them can neither declare
* nor use anything.
Comment on lines +46 to +49
*/
def stripCommentsAndStrings(text: String): String = {
val n = text.length
val out = new Array[Char](n)

def isTripleQuoteAt(pos: Int): Boolean =
pos + 3 <= n && text.charAt(pos) == '"' && text.charAt(pos + 1) == '"' &&
text.charAt(pos + 2) == '"'

def isEscapedTripleQuoteAt(pos: Int): Boolean =
pos + 4 <= n && text.charAt(pos) == '\\' && isTripleQuoteAt(pos + 1)

var i = 0
while (i < n) {
val c = text.charAt(i)
if (c == '#') {
// Line comment: blank up to (but not including) the newline, so line structure survives.
while (i < n && text.charAt(i) != '\n') { out(i) = ' '; i += 1 }
} else if (isTripleQuoteAt(i)) {
// Block string: blank the opening `"""`, then scan for the closing one, treating `\"""` as
// an escaped triple quote that doesn't close it.
out(i) = ' '; out(i + 1) = ' '; out(i + 2) = ' '
i += 3
var closed = false
while (i < n && !closed)
if (isEscapedTripleQuoteAt(i)) {
out(i) = ' '; out(i + 1) = ' '; out(i + 2) = ' '; out(i + 3) = ' '
i += 4
} else if (isTripleQuoteAt(i)) {
out(i) = ' '; out(i + 1) = ' '; out(i + 2) = ' '
i += 3
closed = true
} else {
out(i) = ' '; i += 1
}
} else if (c == '"') {
// Single-line string: a backslash escapes the next char (whatever it is); an unescaped `"`
// closes it. Unterminated: blanks to end of input.
out(i) = ' '; i += 1
var closed = false
while (i < n && !closed)
if (text.charAt(i) == '\\' && i + 1 < n) {
out(i) = ' '; out(i + 1) = ' '; i += 2
} else if (text.charAt(i) == '"') {
out(i) = ' '; i += 1
closed = true
} else {
out(i) = ' '; i += 1
}
} else {
out(i) = c; i += 1
}
}
new String(out)
}

/**
* Span `(open, end)` of the operation header's var-defs in `query (...) { ... }`, if any. A `(`
* after the first `{` belongs to a field's arguments, not a header: a subquery body like
* `{ hero(episode: $ep) }` has none.
*/
def headerSpan(h: String): Option[(Int, Int)] = {
val open = h.indexOf('(')
val brace = h.indexOf('{')
if (open < 0 || (brace >= 0 && brace < open)) None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

headerSpan bails when any { precedes the first (, so a document with fragment definitions before the operation (fragment f on C { id } query ($ep: Episode!) …) loses operationVars. Splicing a subquery into such a document now fails with a false compile error "operation does not declare variable $ep". The old operationVars had no brace guard and handled it; gen/input/.../StarWarsQuery2.scala is an existing fixture of this exact shape. Guard should key off the query/mutation/subscription keyword instead.

else {
var depth = 0; var i = open; var end = -1
while (i < h.length && end < 0) {
h.charAt(i) match {
case '(' => depth += 1
case ')' => depth -= 1; if (depth == 0) end = i
case _ => ()
}
i += 1
}
if (end < 0) None else Some((open, end))
}
}

/** Parse a parenthesized var-def list `($a: T, $b: U)` into name -> GraphQL type. */
def parseVarDefs(s0: String): Map[String, String] = {
val s = s0.trim.stripPrefix("(").stripSuffix(")").trim.replace("$$", "$")
if (s.isEmpty) Map.empty
else
splitTopLevel(s).flatMap { entry =>
val e = entry.trim
val nameEnd = e.indexOf(':')
if (nameEnd < 0 || !e.startsWith("$")) None
else {
val name = e.substring(1, nameEnd).trim
val tpe = e.substring(nameEnd + 1).takeWhile(_ != '=').trim
Some(name -> tpe)
}
}.toMap
}

private def splitTopLevel(s: String): List[String] = {
val out = scala.collection.mutable.ListBuffer.empty[String]
val cur = new StringBuilder
var depth = 0
s.foreach {
case '[' => depth += 1; cur += '['
case ']' => depth -= 1; cur += ']'
case ',' if depth == 0 => out += cur.toString; cur.clear()
case c => cur += c
}
if (cur.nonEmpty) out += cur.toString
out.toList
}

/**
* GraphQL "is variable usage allowed": the declared type must be usable where the required type
* is expected. Same base type, and a non-null requirement needs a non-null declared type.
*/
def usableAs(declaredType: String, reqType: String): Boolean = {
val d = declaredType.trim; val r = reqType.trim
d.stripSuffix("!").trim == r.stripSuffix("!").trim && (!r.endsWith("!") || d.endsWith("!"))
}

private val VarRef = """\$(\w+)""".r
private val FragmentDef = """\bfragment\s+(\w+)\s+on\b""".r
// `... on Type` is an inline fragment, not a spread.
private val FragmentUse = """\.\.\.\s*(?!on\b)(\w+)""".r

/**
* Declared variables that are neither referenced in `body` nor required by a spliced subquery
* (GraphQL "All Variables Used"). Sorted for deterministic reporting.
*/
def unusedVariables(
declared: Set[String],
body: String,
requiredBySplices: Set[String]
): List[String] = {
val used = VarRef.findAllMatchIn(body).map(_.group(1)).toSet ++ requiredBySplices
(declared -- used).toList.sorted
}

/**
* Fragments defined in `body` but never spread there (GraphQL "Fragments Must Be Used"). Spliced
* subqueries are validated in isolation, so they can neither spread nor define the host's
* fragments; the text is the whole story.
*/
def unusedFragments(body: String): List[String] = {
val defined = FragmentDef.findAllMatchIn(body).map(_.group(1)).toSet
val spread = FragmentUse.findAllMatchIn(body).map(_.group(1)).toSet
(defined -- spread).toList.sorted
}
}
22 changes: 22 additions & 0 deletions core/src/test/scala/clue/GraphQLInterpolatorSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,25 @@ class GraphQLInterpolatorSuite extends munit.FunSuite {
assertEquals(doc.value, "query ($ep: Episode!) { friends { hero(episode: $ep) { name } } }")
}
}

// The unused-declaration checks are warnings, which `compileErrors` can't observe, so the text
// scanning behind them is unit-tested in `GraphQLTextSuite`. These only pin the positive cases
// through the macro: with fatal warnings on (CI), a false positive here fails the build.
class GraphQLInterpolatorUnusedSuite extends munit.FunSuite {

test("a variable used only by a spliced subquery is not reported") {
val doc = gql"query ($$ep: Episode!) $InterpolatorTestSub"
assertEquals(doc.value, "query ($ep: Episode!) { hero(episode: $ep) { name } }")
}

test("a variable used in the text is not reported") {
val doc = gql"query ($$id: ID!) { character(id: $$id) { name } }"
assertEquals(doc.value, "query ($id: ID!) { character(id: $id) { name } }")
}

test("a spread fragment is not reported, and an inline fragment is not a spread") {
val doc =
gql"query { hero { ...fields ... on Droid { primaryFunction } } } fragment fields on Character { id }"
assert(doc.value.contains("fragment fields"))
}
}
Loading
0