Skip to content

Commit

Permalink
initial commit (#439)
Browse files Browse the repository at this point in the history
  • Loading branch information
JerrySentry authored Nov 28, 2024
1 parent 9d4a1db commit dfa96d7
Show file tree
Hide file tree
Showing 3 changed files with 649 additions and 1 deletion.
297 changes: 297 additions & 0 deletions shared/bundle_analysis/utils.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,222 @@
import logging
import os
import re
from enum import Enum
from pathlib import Path
from typing import List, Optional

log = logging.getLogger(__name__)


class PluginName(Enum):
REMIX_VITE = "@codecov/remix-vite-plugin"
NEXTJS_WEBPACK = "@codecov/nextjs-webpack-plugin"
NUXT = "@codecov/nuxt-plugin"
SOLIDSTART = "@codecov/solidstart-plugin"
SVELTEKIT = "@codecov/sveltekit-plugin"


class AssetRoute:
def __init__(
self, plugin: PluginName, configured_route_prefix: Optional[str] = None
) -> None:
self._from_filename_map = {
PluginName.REMIX_VITE: (self._compute_remix, ["app", "routes"]),
PluginName.NEXTJS_WEBPACK: (self._compute_nextjs_webpack, "app"),
PluginName.NUXT: (self._compute_nuxt, "pages"),
PluginName.SOLIDSTART: (self._compute_solidstart, ["src", "routes"]),
PluginName.SVELTEKIT: (self._compute_sveltekit, ["src", "routes"]),
}
self._compute_from_filename = self._from_filename_map[plugin][0]

if configured_route_prefix is not None:
self._prefix = configured_route_prefix
else:
self._prefix = self._from_filename_map[plugin][1]

def _is_file(self, s: str, extensions: Optional[List[str]] = None) -> bool:
"""
Determines if the passed string represents a file with one or more dots,
and optionally verifies if it ends with a specific extension.
Args:
s (str): The string to check.
extension (Optional[str]): The file extension to validate (e.g., "vue").
Returns:
bool: True if the string represents a valid file, False otherwise.
"""
# If a list of extensions is provided, check if the string ends with it
if extensions is not None and not any(
[s.endswith(f".{e}") for e in extensions]
):
return False

# Matches strings with at least one non-dot character before the first dot
# and at least one non-dot character after the last dot.
file_regex = re.compile(r"^[^/\\]+?\.[^/\\]+$")
return bool(file_regex.match(s))

def _compute_remix(self, filename: str) -> Optional[str]:
"""
Computes the route for Next.js Webpack plugin.
Doc: https://remix.run/docs/en/main/file-conventions/routes
"""
path_items = Path(filename).parts

# Check if contains at least 3 parts (2 prefix and suffix)
if len(path_items) < 3:
return None

# Check if 2 prefix is present
if path_items[0] != self._prefix[0] or path_items[1] != self._prefix[1]:
return None

# Remove parameters after extension
file = path_items[-1]
if file.rfind("?") >= 0:
file = file[: file.rfind("?")]

# Check if suffix is a file that with valid extensions
if not self._is_file(file, extensions=["tsx", "ts", "jsx", "js"]):
return None

# Get the file name without extension
file = path_items[-1]
file = file[: file.rfind(".")]

returned_path = list(path_items[2:-1])

# Split the file by . to build the route with special rules
file_items = split_by_delimiter(
file, delimiter=".", escape_open="[", escape_close="]"
)
for item in file_items:
if not item.startswith("_"):
if item.endswith("_"):
returned_path.append(item[:-1])
else:
returned_path.append(item)

# Build path from items excluding prefix and suffix
return "/" + "/".join(returned_path)

def _compute_nextjs_webpack(self, filename: str) -> Optional[str]:
"""
Computes the route for Next.js Webpack plugin.
Doc: https://nextjs.org/docs/app/building-your-application/routing
"""
path_items = Path(filename).parts

# Check if contains at least 2 parts (prefix and suffix)
if len(path_items) < 2:
return None

# Check if prefix is present and suffix is a file type
if path_items[0] != self._prefix or not self._is_file(path_items[-1]):
return None

# Build path from items excluding prefix and suffix
return "/" + "/".join(path_items[1:-1])

def _compute_nuxt(self, filename: str) -> Optional[str]:
"""
Computes the route for Nuxt plugin.
Doc: https://nuxt.com/docs/getting-started/routing
"""
path_items = Path(filename).parts

# Check if contains at least 2 parts (prefix and suffix)
if len(path_items) < 2:
return None

# Check if prefix is present and suffix is a file type that has .vue extension
if path_items[0] != self._prefix or not self._is_file(path_items[-1], ["vue"]):
return None

# Remove .vue from last path item
path_items = list(path_items)
path_items[-1] = path_items[-1][:-4]

# Drop file index if exists
if path_items[-1] == "index":
path_items.pop()

# Build path from items excluding prefix
return "/" + "/".join(path_items[1:])

def _compute_solidstart(self, filename: str) -> Optional[str]:
"""
Computes the route for SolidtStart plugin.
Doc: https://docs.solidjs.com/solid-start/building-your-application/routing#file-based-routing
"""
path_items = Path(filename).parts

# Check if contains at least 3 parts (2 prefix and suffix)
if len(path_items) < 3:
return None

# Check if 2 prefix is present
if path_items[0] != self._prefix[0] or path_items[1] != self._prefix[1]:
return None

# Check if suffix is a file that with valid extensions
if not self._is_file(path_items[-1], extensions=["tsx", "ts", "jsx", "js"]):
return None

# Remove route groups and renamed indices, ie remove character inside parenthesis and itself
returned_items = [re.sub(r"\(.*?\)", "", item) for item in path_items]

# Get the file name without extension
file = returned_items[-1]
file = file[: file.rfind(".")]
returned_items[-1] = file

# Remove index file if exists
if returned_items[-1] == "index":
returned_items.pop()

# Build path from items excluding prefix and suffix
return "/" + "/".join([item for item in returned_items[2:] if item != ""])

def _compute_sveltekit(self, filename: str) -> Optional[str]:
"""
Computes the route for SvelteKit plugin.
Doc: https://svelte.dev/docs/kit/routing
"""
path_items = Path(filename).parts

# Check if contains at least 3 parts (2 prefix and suffix)
if len(path_items) < 3:
return None

# Check if 2 prefix is present
if path_items[0] != self._prefix[0] or path_items[1] != self._prefix[1]:
return None

# Check if suffix is a file that starts with "+"
if not self._is_file(path_items[-1]) or not path_items[-1].startswith("+"):
return None

# Build path from items excluding 2 prefix and suffix
return "/" + "/".join(path_items[2:-1])

def get_from_filename(self, filename: str) -> Optional[str]:
"""
Computes the route.
Args:
filename (str): The file path to compute the route from.
Returns:
Optional[str]: The computed route or None if invalid.
"""
try:
return self._compute_from_filename(filename)
except Exception as e:
log.error(
f"Uncaught error during AssetRoute path compute: {e}", exc_info=True
)
return None


def get_extension(filename: str) -> str:
Expand All @@ -18,3 +236,82 @@ def get_extension(filename: str) -> str:
file_extension = file_extension.split("?")[0]

return file_extension


def split_by_delimiter(
s: str,
delimiter: str,
escape_open: Optional[str] = None,
escape_close: Optional[str] = None,
) -> List[str]:
"""
Splits a string based on a specified delimiter character, optionally respecting escape delimiters.
Parameters:
----------
s : str
The input string to split.
delimiter : str
The character used to split the string. Must be a single character.
escape_open : Optional[str], default=None
The character indicating the start of an escaped section.
If provided, must be a single character.
escape_close : Optional[str], default=None
The character indicating the end of an escaped section.
If provided, must be a single character.
Returns:
-------
List[str]
A list of substrings obtained by splitting `s` at occurrences of `delimiter`,
unless the delimiter is within an escaped section.
Returns an empty list if input parameters are invalid.
"""
# Error handling for invalid parameters
if not s:
return []
if not isinstance(delimiter, str) or len(delimiter) != 1:
return []
if (
escape_open is not None
and (not isinstance(escape_open, str) or len(escape_open) != 1)
) or (
escape_close is not None
and (not isinstance(escape_close, str) or len(escape_close) != 1)
):
return []
if (escape_open is None) != (escape_close is None): # Only one of them is None
return []
if delimiter == escape_open or delimiter == escape_close:
return []

result = []
buffer = []
inside_escape = 0

for char in s:
if char == escape_open:
inside_escape += 1
if inside_escape == 1:
continue # Skip adding the opening escape character
elif char == escape_close:
inside_escape -= 1
if inside_escape == 0:
continue # Skip adding the closing escape character
elif inside_escape < 0:
return []
elif char == delimiter and inside_escape == 0:
# Split here if not inside escape brackets
result.append("".join(buffer))
buffer = []
continue

buffer.append(char)

if buffer or s[-1] == delimiter:
result.append("".join(buffer))

if inside_escape != 0:
return []

return result
2 changes: 1 addition & 1 deletion shared/helpers/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
def attempt_json_dumps(value: Any) -> str:
def assert_string_keys(d: dict[Any, Any]) -> None:
for k, v in d.items():
if type(k) is not str:
if not isinstance(k, str):
raise TypeError(
f"Attempted to JSON-serialize a dictionary with non-string key: {k}"
)
Expand Down
Loading

0 comments on commit dfa96d7

Please sign in to comment.