blob: f5ffa1ea9f81987111fe5677a78560c707eda706 [file] [log] [blame]
"""A module containing a representation of an intellij IDE installation."""
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Set, Dict
import json
import re
import sys
import xml.etree.ElementTree as ET
import zipfile
LINUX = "linux"
WIN = "windows"
MAC = "darwin"
MAC_ARM = "darwin_aarch64"
_idea_home = {
LINUX: "",
WIN: "",
MAC: "Contents",
MAC_ARM: "Contents",
}
_idea_resources = {
LINUX: "",
WIN: "",
MAC: "Contents/Resources",
MAC_ARM: "Contents/Resources",
}
@dataclass(frozen=True)
class IntelliJ:
major: str
minor: str
platform: str = ""
platform_jars: Set[str] = field(default_factory=lambda: set())
plugin_jars: Dict[str, Set[str]] = field(default_factory=lambda: dict())
jvm_add_exports: List[str] = field(default_factory=lambda: list())
jvm_add_opens: List[str] = field(default_factory=lambda: list())
def version(self):
return self.major, self.minor
def create(platform: str, path: Path):
product_info = read_product_info(
path/_idea_resources[platform]/"product-info.json"
)
prefix = _read_platform_prefix(product_info)
major, minor = read_version(path/_idea_home[platform]/"lib", prefix)
jars = read_platform_jars(path/_idea_home[platform], product_info)
plugin_jars = _read_plugin_jars(path/_idea_home[platform])
add_exports = _read_jvm_args("--add-exports=","=ALL-UNNAMED", product_info)
add_opens = _read_jvm_args("--add-opens=","=ALL-UNNAMED", product_info)
return IntelliJ(major, minor, platform_jars=jars, plugin_jars=plugin_jars, jvm_add_exports=add_exports, jvm_add_opens=add_opens)
def read_product_info(path):
with open(path) as f:
return json.load(f)
def read_version(lib_dir: Path, prefix: str) -> (str, str):
contents = None
for resources_jar in lib_dir.glob("*.jar"):
with zipfile.ZipFile(resources_jar) as zip:
file_name = f"idea/{prefix}ApplicationInfo.xml"
if file_name in zip.namelist():
data = zip.read(file_name)
contents = data.decode("utf-8")
break
if not contents:
sys.exit("Failed to find ApplicationInfo.xml for idea.prefix=" + prefix)
m = re.search(r'<version.*major="([\d\.]+)".*minor="([\d\.]+)".*>', contents)
major = m.group(1)
minor = m.group(2)
return major, minor
def read_platform_jars(ide_home: Path, product_info):
# Extract the runtime classpath from product-info.json.
bootClassPath = product_info["launch"][0]["bootClassPathJarNames"]
jars = ["/lib/" + jar for jar in bootClassPath]
return set(jars)
def _read_platform_prefix(product_info):
launch_config = product_info["launch"][0]
for define in launch_config["additionalJvmArguments"]:
m = re.search("-Didea.platform.prefix=(.*)", define)
if m:
return m.group(1)
# IJ ultimate has a platform prefix if "", so it's not added here. If not found assume it's empty.
return ""
def _read_zip_entry(zip_path, entry):
with zipfile.ZipFile(zip_path) as zip:
if entry not in zip.namelist():
return None
data = zip.read(entry)
return data.decode("utf-8")
def _read_plugin_id(path: Path):
jars = path.glob("lib/*.jar")
xml = load_plugin_xml(jars)
# The id of a plugin is defined as the id tag and if missing, the name tag.
ids = [id.text for id in xml.findall("id")]
if len(set(ids)) > 1:
sys.exit(f"Too many plugin ids found in plugin: {path}")
if len(ids) >= 1:
return ids[0]
names = xml.findall("name")
if len(names) > 1:
sys.exit(f"Too many plugin names found (for plugin without id): {path}")
if len(names) == 1:
return names[0].text
sys.exit(f"Cannot find plugin id or name tag for plugin: {path}")
def _read_plugin_jars(idea_home: Path):
plugins = {}
for plugin_path in idea_home.glob("plugins/*"):
if not plugin_path.is_dir():
continue
plugin_id = _read_plugin_id(plugin_path)
jars = plugin_path.glob("lib/*.jar")
jar_paths = ["/" + str(jar.relative_to(idea_home).as_posix()) for jar in jars]
assert plugin_id not in plugins, f"Duplicated plugin ID: {plugin_id}"
plugins[plugin_id] = set(jar_paths)
# We also model V2 modules as plugins---at least for now, until the V2 design solidifies upstream.
# See b/349849955 and go/studio-v2-modules for details.
for jar in [*idea_home.glob("lib/modules/*.jar"), *idea_home.glob("plugins/*/lib/modules/*.jar")]:
module_id = jar.stem
jar_path = "/" + str(jar.relative_to(idea_home).as_posix())
assert module_id not in plugins, f"Duplicated plugin ID: {module_id}"
plugins[module_id] = set([jar_path])
return plugins
def _load_include(include, xpath, cwd, index):
href = include.get("href")
parse = include.get("parse", "xml")
if parse != "xml":
print("only xml parse is supported")
sys.exit(1)
is_optional = any(
child.tag == "{http://www.w3.org/2001/XInclude}fallback"
for child in include
)
if is_optional:
return [], None
# See `PluginXmlPathResolver.toLoadPath` for the platform implementation
rel = href
if rel not in index:
if href.startswith("/"):
rel = href[1:]
elif cwd == "":
# By default, plugin xmls are resolved from META-INF
rel = "META-INF/" + href
else:
rel = cwd + "/" + href
new_cwd = rel[0 : rel.rindex("/")] if "/" in rel else ""
if rel not in index:
print("Cannot find file to include %s" % href)
sys.exit(1)
res = index[rel].read(rel)
e = ET.fromstring(res)
ret = []
assert xpath.startswith("/")
root, path = xpath[1:].split("/", 1)
if root == e.tag:
ret = e.findall("./" + path)
if not ret:
print("While including %s, the path %s," % (rel, xpath))
print("did not produce any elements to include")
sys.exit(1)
return ret, new_cwd
def _xpath_for_include(include, parent):
# The IntelliJ plugin XML reader has custom handling for <xi:include> elements, which we emulate
# here. For example, it has hard-coded defaults and constraints for the xpointer attribute. See:
# https://github.com/JetBrains/intellij-community/blob/f57df70730/platform/core-impl/src/com/intellij/ide/plugins/XmlReader.kt#L39
if parent.tag == "idea-plugin":
xpath = "/idea-plugin/*"
elif parent.tag == "extensionPoints":
xpath = "/idea-plugin/extensionPoints/*"
else:
sys.exit(f"<xi:include> is unsupported beneath <{parent.tag}>")
# Check that the xpointer attribute (if any) is consistent with our inferred xpath.
xpointer = include.get("xpointer")
if xpointer != None and xpointer != f"xpointer({xpath})":
sys.exit(f"<xi:include> has invalid xpointer attribute: {xpointer}")
return xpath
def _resolve_includes(elem, cwd, index):
"""Resolves xincludes in the given xml element.
By replacing xinclude tags like
<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude">
<xi:include href="/META-INF/android-plugin.xml"/>
...
with the xml pointed by href and the xpath given in xpointer.
"""
i = 0
while i < len(elem):
e = elem[i]
if e.tag == "{http://www.w3.org/2001/XInclude}include":
xpath = _xpath_for_include(e, elem)
nodes, new_cwd = _load_include(e, xpath, cwd, index)
subtree = ET.Element(elem.tag)
subtree.extend(nodes)
_resolve_includes(subtree, new_cwd, index)
nodes = list(subtree)
if nodes:
for node in nodes[:-1]:
elem.insert(i, node)
i = i + 1
node = nodes[len(nodes) - 1]
if e.tail:
node.tail = (node.tail or "") + e.tail
elem[i] = node
else:
_resolve_includes(e, cwd, index)
i = i + 1
def load_plugin_xml(files: List[Path], xml_name = "META-INF/plugin.xml"):
xmls = {}
index = {}
jars = [zipfile.ZipFile(f) for f in files if f.suffix == ".jar"]
for jar in jars:
for jar_entry in jar.namelist():
if jar_entry == xml_name:
xmls[f"{jar.filename}!{jar_entry}"] = jar.read(jar_entry)
if not jar_entry.endswith("/"):
# TODO: Investigate if we can have a strict mode where we fail on duplicate
# files across jars in the same plugin. Currently even IJ plugins fail with
# such a check as they have even .class files duplicated in the same plugin.
index[jar_entry] = jar
if len(xmls) != 1:
for file in xmls:
print(f"Found {xml_name} at {file}")
sys.exit(f"ERROR: plugin should have exactly one file named {xml_name} (found {len(xmls)})")
_, xml = list(xmls.items())[0]
element = ET.fromstring(xml)
# We cannot use ElementInclude because it does not support xpointer
_resolve_includes(element, "META-INF", index)
for jar in jars:
jar.close()
return element
def _read_jvm_args(prefix, suffix, product_info):
"""Extracts and sorts JVM arguments that start with a prefix and end with a suffix.
Args:
prefix: The prefix string.
suffix: The suffix string.
product_info: A dictionary containing product information, including JVM arguments.
Returns:
A sorted list of strings containing the arguments without the prefix and suffix,
or an empty list if no matching arguments are found.
"""
jvm_args = product_info["launch"][0]["additionalJvmArguments"]
result = []
for arg in jvm_args:
if arg.startswith(prefix) and arg.endswith(suffix):
result.append(arg[len(prefix):-len(suffix)])
result.sort()
return result