Skip to content

Commit 2e922fd

Browse files
authored
fix: parse static Gradle plugin namespaces (#34)
1 parent 89f4ef5 commit 2e922fd

44 files changed

Lines changed: 821 additions & 76 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.bazelignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ examples/no_plugins
44
examples/pub_plugins
55
examples/local_plugin
66
tests/core_consumer
7+
tests/namespace_dynamic

.github/workflows/ci.yml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,24 @@ jobs:
7474
set -o pipefail
7575
env -u ANDROID_HOME -u ANDROID_NDK_HOME bazel test //... 2>&1 | tee bazel_test_root.log
7676
77+
- name: Dynamic namespace rejection
78+
working-directory: tests/namespace_dynamic
79+
run: |
80+
set -euo pipefail
81+
output="$RUNNER_TEMP/dynamic_namespace.log"
82+
if bazel build //:dynamic_namespace_failure 2>&1 | tee "$output"; then
83+
echo "dynamic namespace build unexpectedly succeeded" >&2
84+
exit 1
85+
fi
86+
for expected in \
87+
"Unsupported namespace expression" \
88+
"android/build.gradle" \
89+
'"com.example." + project.name'; do
90+
if ! grep -Fq "$expected" "$output"; then
91+
echo "dynamic namespace output lacked: $expected" >&2
92+
exit 1
93+
fi
94+
done
7795
- name: Core Consumer isolation
7896
working-directory: tests/core_consumer
7997
run: |

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
/tests/consumer/bazel-*
66
/tests/core_consumer/bazel-*
77
/examples/*/bazel-*
8+
/tests/namespace_dynamic/bazel-*
89

910
# Python bytecode from the CI helper scripts.
1011
__pycache__/

MODULE.bazel.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/demo_app/MODULE.bazel.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/local_plugin/MODULE.bazel.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/no_plugins/MODULE.bazel.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

examples/pub_plugins/MODULE.bazel.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flutter/private/plugins.bzl

Lines changed: 203 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -700,35 +700,213 @@ def _dart_plugin_class(ctx, package_root, name):
700700
# Parenthesised because buildifier cannot parse a bare conditional here.
701701
return dart_class, (dart_file if dart_file else "{}.dart".format(name))
702702

703-
def _namespace(build_gradle_text):
704-
"""Read the `namespace` AGP assigns the module, from build.gradle.
705-
706-
AGP 7 deprecated the manifest's `package=` and AGP 8 removed it, so a plugin
707-
written against a current AGP declares its package name here instead and
708-
ships a bare `<manifest />`. This is the authoritative source of the two:
709-
where both exist AGP errors on a disagreement rather than reconciling them.
710-
711-
Matched line by line rather than by substring, so that `testNamespace` and a
712-
coordinate mentioning the word are not mistaken for it. `namespace 'x'`
713-
(Groovy) and `namespace = "x"` (Kotlin, and the Groovy assignment form) are
714-
both accepted.
703+
def _namespace(build_gradle_text, source_path, strict = True):
704+
"""Read the static `namespace` AGP assigns the module.
705+
706+
This is deliberately a small lexer rather than a substring search. Gradle
707+
has both Groovy and Kotlin syntax in the wild, and the word also appears in
708+
comments, strings, and identifiers such as `testNamespace`. A declaration
709+
that is present but not a literal is an error in the standard generator:
710+
treating it as absent would incorrectly fall through to a manifest that AGP
711+
itself does not use. Package recipes set `strict = False` because their
712+
custom implementation may know how to handle a dynamic namespace.
715713
"""
716-
for line in _strip_buildscript(build_gradle_text).splitlines():
717-
stripped = line.strip()
718-
if not stripped.startswith("namespace"):
714+
text = _strip_buildscript(build_gradle_text)
715+
i = 0
716+
for _ in range(len(text)):
717+
if i >= len(text):
718+
break
719+
c = text[i]
720+
if text[i:i + 2] == "//":
721+
newline = text.find("\n", i + 2)
722+
i = len(text) if newline == -1 else newline + 1
719723
continue
720-
rest = stripped[len("namespace"):].lstrip()
721-
if rest.startswith("="):
722-
rest = rest[1:].lstrip()
723-
if not rest or rest[0] not in "'\"":
724+
if text[i:i + 2] == "/*":
725+
end = text.find("*/", i + 2)
726+
i = len(text) if end == -1 else end + 2
724727
continue
725-
quote = rest[0]
726-
end = rest.find(quote, 1)
727-
if end == -1:
728+
if c in ["'", "\""]:
729+
i = _namespace_string_end(text, i)
730+
continue
731+
if not (c.isalpha() or c == "_"):
732+
i += 1
733+
continue
734+
735+
start = i
736+
i += 1
737+
for _ in range(len(text)):
738+
if i >= len(text) or not (text[i].isalnum() or text[i] == "_"):
739+
break
740+
i += 1
741+
if text[start:i] != "namespace":
728742
continue
729-
return rest[1:end]
743+
744+
# Only a DSL statement can introduce the module namespace. In
745+
# particular, do not interpret `foo.namespace` or a namespace named as
746+
# an argument to an unrelated call.
747+
previous = start - 1
748+
at_line_start = previous < 0
749+
for _ in range(len(text)):
750+
if previous < 0:
751+
at_line_start = True
752+
break
753+
if not text[previous].isspace():
754+
break
755+
if text[previous] == "\n":
756+
at_line_start = True
757+
break
758+
previous -= 1
759+
if not at_line_start and (previous < 0 or text[previous] not in ["{", "}", ";"]):
760+
continue
761+
762+
value, expression = _parse_namespace_declaration(text, i)
763+
if value != None:
764+
return value
765+
if strict:
766+
fail("Unsupported namespace expression in {}: {}".format(
767+
source_path,
768+
expression if expression else "<missing expression>",
769+
))
770+
return None
730771
return None
731772

773+
def _namespace_string_end(text, start):
774+
"""Return the end of a quoted Gradle string, or the end of the text."""
775+
quote = text[start]
776+
delimiter = quote * 3 if text[start:start + 3] == quote * 3 else quote
777+
i = start + len(delimiter)
778+
for _ in range(len(text)):
779+
if i >= len(text):
780+
break
781+
if text[i:i + len(delimiter)] == delimiter:
782+
return i + len(delimiter)
783+
if text[i] == "\\":
784+
i += 2
785+
continue
786+
i += 1
787+
return len(text)
788+
789+
def _namespace_tail(text, start):
790+
"""Return a declaration tail, including a continued literal when needed."""
791+
line_end = text.find("\n", start)
792+
if line_end == -1:
793+
line_end = len(text)
794+
first_line = text[start:line_end]
795+
comment = first_line.find("//")
796+
first_code = first_line if comment == -1 else first_line[:comment]
797+
continuation = (
798+
first_code.rstrip().endswith("=") or
799+
first_code.rstrip().endswith("(")
800+
)
801+
if not continuation:
802+
return first_line if comment == -1 else first_line[:comment]
803+
804+
# Assignment and call forms may put their literal on the next line. Keep
805+
# scanning through a call's balanced parentheses, but stop an assignment
806+
# after its first complete line expression. The loop is bounded by the
807+
# input length so malformed Gradle cannot make repository evaluation hang.
808+
depth = 0
809+
seen_value = False
810+
pieces = []
811+
segment_start = start
812+
i = start
813+
for _ in range(len(text)):
814+
if i >= len(text):
815+
pieces.append(text[segment_start:])
816+
return "".join(pieces)
817+
if text[i] in ["'", "\""]:
818+
i = _namespace_string_end(text, i)
819+
seen_value = True
820+
continue
821+
if text[i:i + 2] == "//":
822+
pieces.append(text[segment_start:i])
823+
newline = text.find("\n", i + 2)
824+
if newline == -1:
825+
return "".join(pieces)
826+
segment_start = newline
827+
i = newline
828+
continue
829+
c = text[i]
830+
if c == "(":
831+
depth += 1
832+
elif c == ")":
833+
if depth > 0:
834+
depth -= 1
835+
elif c == "\n":
836+
if depth == 0 and seen_value:
837+
pieces.append(text[segment_start:i])
838+
return "".join(pieces)
839+
elif not c.isspace() and c != "=":
840+
seen_value = True
841+
i += 1
842+
pieces.append(text[segment_start:])
843+
return "".join(pieces)
844+
845+
def _namespace_literal(expression):
846+
"""Return a literal's value, or None when `expression` is not one."""
847+
expression = expression.strip()
848+
if len(expression) < 2 or expression[0] not in ["'", "\""]:
849+
return None
850+
if expression.startswith(expression[0] * 3):
851+
return None
852+
end = _namespace_string_end(expression, 0)
853+
if end == len(expression) and expression[-1] != expression[0]:
854+
return None
855+
if expression[end:].strip():
856+
return None
857+
value = expression[1:end - 1]
858+
if "$" in value or "\\" in value:
859+
return None
860+
return value
861+
862+
def _namespace_structural_tail(tail):
863+
"""Remove syntax closing an inline guard and return the remaining text."""
864+
tail = tail.strip()
865+
for _ in range(len(tail)):
866+
if not tail or tail[-1] not in ["}", ";"]:
867+
break
868+
tail = tail[:-1].rstrip()
869+
return tail
870+
871+
def _parse_namespace_declaration(text, after_name):
872+
"""Return (literal value, unsupported expression) for one declaration."""
873+
tail = _namespace_tail(text, after_name)
874+
stripped = tail.lstrip()
875+
if stripped.startswith("="):
876+
expression = _namespace_structural_tail(stripped[1:])
877+
return _namespace_literal(expression), expression
878+
879+
if stripped.startswith("("):
880+
depth = 0
881+
end = -1
882+
i = 0
883+
for _ in range(len(stripped)):
884+
if i >= len(stripped):
885+
break
886+
if stripped[i] in ["'", "\""]:
887+
i = _namespace_string_end(stripped, i)
888+
continue
889+
if stripped[i] == "(":
890+
depth += 1
891+
elif stripped[i] == ")":
892+
depth -= 1
893+
if depth == 0:
894+
end = i
895+
break
896+
i += 1
897+
if end == -1:
898+
expression = _namespace_structural_tail(stripped[1:])
899+
return None, expression
900+
expression = stripped[1:end].strip()
901+
trailing = _namespace_structural_tail(stripped[end + 1:])
902+
if trailing:
903+
expression = "{} {}".format(expression, trailing).strip()
904+
return None, expression
905+
return _namespace_literal(expression), expression
906+
907+
expression = _namespace_structural_tail(stripped)
908+
return _namespace_literal(expression), expression
909+
732910
def _manifest_package(ctx, manifest_path):
733911
"""Read the `package=` attribute from a library manifest.
734912
@@ -1066,7 +1244,7 @@ def _flutter_plugins_impl(ctx):
10661244
_list_files(root, "android/src/main/kotlin", [".kt"]) +
10671245
_list_files(root, "android/src/main/java", [".kt"])
10681246
)
1069-
package = _namespace(ctx.read(build_gradle))
1247+
package = _namespace(ctx.read(build_gradle), str(build_gradle), strict = False)
10701248
if not package:
10711249
manifest_path = root.get_child("android/src/main/AndroidManifest.xml")
10721250
if manifest_path.exists:
@@ -1175,8 +1353,7 @@ def _flutter_plugins_impl(ctx):
11751353
# manifest was enough for the demo app's plugins and is not enough in
11761354
# the wild: four of smooth_app's thirty carry a bare `<manifest />` and
11771355
# name themselves in build.gradle, while qr_code_scanner is the mirror
1178-
# case, predating `namespace` entirely.
1179-
package = _namespace(ctx.read(build_gradle))
1356+
package = _namespace(ctx.read(build_gradle), str(build_gradle))
11801357
if not package:
11811358
package = _manifest_package(ctx, root.get_child("android/src/main/AndroidManifest.xml"))
11821359
if not package:

tests/consumer/BUILD.bazel

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,18 @@ pub_plugins_check(
394394
expected = "@flutter_plugins//:plugin_deps.MODULE.bazel",
395395
)
396396

397+
build_test(
398+
name = "namespace_plugin_test",
399+
targets = [
400+
# Each source package references its generated R class. An incorrect
401+
# custom_package (the namespace parser's output) therefore fails javac.
402+
"@flutter_plugins//namespace_bare:namespace_bare",
403+
"@flutter_plugins//namespace_call:namespace_call",
404+
"@flutter_plugins//namespace_assignment:namespace_assignment",
405+
"@flutter_plugins//namespace_guarded:namespace_guarded",
406+
],
407+
)
408+
397409
build_test(
398410
name = "fake_plugin_test",
399411
targets = [

0 commit comments

Comments
 (0)