"""
Static analysis submodule that defines the highly limited "strict"
subset of the Nada DSL syntax.
"""
# pylint: disable=wildcard-import,unused-wildcard-import,invalid-name
from __future__ import annotations
from typing import Callable
import ast
import richreports
try:
from nada_dsl.audit.abstract import *
from nada_dsl.audit.common import *
from nada_dsl.audit.report import parse, enrich_fromaudits
except: # pylint: disable=bare-except # For Nada DSL Sandbox support.
from abstract import *
from common import *
from report import parse, enrich_fromaudits
def _rules_restrictions_descendants(a):
"""
Remove all rule attributes that are redundant because they occur in a
an ancestor.
"""
for a_ in ast.walk(a):
if not isinstance(a_, (ast.Assign, ast.Assign)):
if isinstance(audits(a_, 'rules'), (SyntaxRestriction, RuleInAncestor)):
for a__ in ast.walk(a_):
if a__ != a_:
audits(a__, 'rules', RuleInAncestor())
audits(a__, 'types', delete=True)
[docs]
def rules(a):
"""
Mark all :obj:`ast` nodes as prohibited (as a starting point for a strict
static analysis).
"""
if isinstance(a, ast.Module):
for a_ in ast.walk(a):
audits(
a_,
'rules',
SyntaxRestriction('use of this syntax is prohibited in strict mode')
)
def _types_base(t):
"""
Return a boolean value indicating whether the supplied type is a base
type.
"""
return t in (
bool, int, str,
Integer, PublicInteger, SecretInteger,
Boolean, PublicBoolean, SecretBoolean
)
def _types_list_monomorphic(t):
"""
Return a boolean value indicating whether the supplied type represents a
monomorphic list type (*i.e.*, a list type wherein the types of the items
are fully specified).
"""
if t.__name__ == 'list' and hasattr(t, '__args__') and len(t.__args__) == 1:
return _types_list_monomorphic(t.__args__[0])
if _types_base(t):
return True
return False
def _types_list_monomorphic_depth(t):
"""
Return an integer representing the depth of the monomorphic list type.
"""
if t.__name__ == 'list' and hasattr(t, '__args__') and len(t.__args__) == 1:
return 1 + _types_list_monomorphic_depth(t.__args__[0])
return 0
def _types_monomorphic(t):
"""
Return a boolean value indicating whether the supplied type represents a
monomorphic type permitted by this static analysis submodule.
"""
return _types_base(t) or _types_list_monomorphic(t)
def _types_binop_mult_add_sub(t_l, t_r):
"""
Determine the result type for multiplication and addition operations
involving Nada DSL integers.
"""
t = TypeErrorRoot('arguments must have integer types')
if (t_l, t_r) == (Integer, Integer):
t = Integer
elif (t_l, t_r) == (Integer, PublicInteger):
t = PublicInteger
elif (t_l, t_r) == (PublicInteger, Integer):
t = PublicInteger
elif (t_l, t_r) == (Integer, SecretInteger):
t = SecretInteger
elif (t_l, t_r) == (SecretInteger, Integer):
t = SecretInteger
elif (t_l, t_r) == (PublicInteger, PublicInteger):
t = PublicInteger
elif (t_l, t_r) == (PublicInteger, SecretInteger):
t = SecretInteger
elif (t_l, t_r) == (SecretInteger, PublicInteger):
t = SecretInteger
elif (t_l, t_r) == (SecretInteger, SecretInteger):
t = SecretInteger
if isinstance(t_l, TypeError):
t = typeerror_demote(t_l)
elif isinstance(t_r, TypeError):
t = typeerror_demote(t_r)
return t
def _types_compare(t_l, t_r):
"""
Determine the result type for comparison operations involving Nada DSL
integers.
"""
t = TypeErrorRoot('arguments must have integer types')
if (t_l, t_r) == (Integer, Integer):
t = Boolean
elif (t_l, t_r) == (Integer, PublicInteger):
t = PublicBoolean
elif (t_l, t_r) == (PublicInteger, Integer):
t = PublicBoolean
elif (t_l, t_r) == (Integer, SecretInteger):
t = SecretBoolean
elif (t_l, t_r) == (SecretInteger, Integer):
t = SecretBoolean
elif (t_l, t_r) == (PublicInteger, PublicInteger):
t = PublicBoolean
elif (t_l, t_r) == (PublicInteger, SecretInteger):
t = SecretBoolean
elif (t_l, t_r) == (SecretInteger, PublicInteger):
t = SecretBoolean
elif (t_l, t_r) == (SecretInteger, SecretInteger):
t = SecretBoolean
if isinstance(t_l, TypeError):
t = typeerror_demote(t_l)
elif isinstance(t_r, TypeError):
t = typeerror_demote(t_r)
return t
[docs]
def types(a, env=None, func=False):
"""
Infer types of :obj:`ast` where possible, adding the type (or error)
information in node attributes (and removing syntax restriction attributes
as appropriate).
"""
# pylint: disable=too-many-statements,too-many-return-statements
# pylint: disable=too-many-nested-blocks,too-many-branches
# pylint: disable=too-many-locals
env = {} if env is None else env
# Handle cases in which the input node is a statement.
if isinstance(a, ast.Module):
rules_no_restriction(a)
for a_ in a.body:
env = types(a_, env, func)
# Remove syntax restriction attributes that appear for descendants
# of nodes that already have such attributes.
_rules_restrictions_descendants(a)
return env
if isinstance(a, ast.ImportFrom):
if (
a.module == 'nada_dsl' and
len(a.names) == 1 and a.names[0].name == '*' and a.names[0].asname is None and
a.level == 0
):
rules_no_restriction(a, recursive=True)
return env
if isinstance(a, ast.FunctionDef):
if not func:
if a.name == 'nada_main':
rules_no_restriction(a)
rules_no_restriction(a.args)
# Create a local copy of the environment. Only the
# original environment passed to this invocation
# is returned.
env_ = dict(env)
for a_ in a.body:
env_ = types(a_, env_, func=True)
else:
rules_no_restriction(a)
rules_no_restriction(a.args)
t_ret = eval(ast.unparse(a.returns)) # pylint: disable=eval-used
if _types_monomorphic(t_ret):
rules_no_restriction(a.returns)
env_ = dict(env)
ts = []
for arg in a.args.args:
var = arg.arg
t_var = eval(ast.unparse(arg.annotation)) # pylint: disable=eval-used
if _types_monomorphic(t_var):
rules_no_restriction(arg)
rules_no_restriction(arg.annotation, recursive=True)
env_[var] = t_var
ts.append(t_var)
for a_ in a.body:
env_ = types(a_, env_, func=True)
env[a.name] = Callable[ts, t_ret]
return env
if isinstance(a, ast.Assign):
if len(a.targets) == 1:
target = a.targets[0]
if isinstance(target, ast.Name):
rules_no_restriction(a)
rules_no_restriction(target)
types(a.value, env, func)
t = audits(a.value, 'types')
if t == list and not _types_list_monomorphic(t):
t = TypeErrorRoot(
'assignment of list value with underspecified ' +
'type requires fully specified type annotation'
)
audits(a, 'types', t)
elif t is not None:
audits(a, 'types', typeerror_demote(t))
audits(target, 'types', TypeInParent())
if not isinstance(t, TypeError):
if isinstance(target, ast.Name):
var = target.id
env[var] = t
elif isinstance(target, ast.Subscript):
rules_no_restriction(a)
types(a.value, env, func)
t = audits(a.value, 'types')
target_ = target
invalid_index = False
depth = 0
while isinstance(target_, ast.Subscript):
depth += 1
rules_no_restriction(target_)
types(target_.slice, env, func)
t_s = audits(target_.slice, 'types')
if t_s != int:
invalid_index = True
break
if isinstance(target_.value, (ast.Name, ast.Subscript)):
target_ = target_.value
if invalid_index:
audits(a, 'types', TypeErrorRoot('indices must be integers'))
if isinstance(target_, ast.Name):
rules_no_restriction(target)
rules_no_restriction(target_)
if target_.id in env:
t_b = env[target_.id]
if _types_list_monomorphic_depth(t_b) < depth:
audits(a, 'types', TypeErrorRoot('target has incompatible type'))
else:
audits(a, 'types', typeerror_demote(t))
else:
audits(a, 'types', TypeErrorRoot('unbound variable: ' + target_.id))
return env
if isinstance(a, ast.AnnAssign):
if isinstance(a.target, ast.Name):
rules_no_restriction(a)
rules_no_restriction(a.target)
types(a.value, env, func)
t = audits(a.value, 'types')
try:
t_a = eval(ast.unparse(a.annotation)) # pylint: disable=eval-used
rules_no_restriction(a.annotation, recursive=True)
if not _types_list_monomorphic(t_a):
audits(
a.annotation,
'types',
TypeErrorRoot(
'assignment of list value requires fully specified type annotation'
)
)
except: # pylint: disable=bare-except
t_a = TypeErrorRoot('invalid type annotation')
if isinstance(t, TypeError):
audits(a, 'types', t)
audits(a.target, 'types', t)
else:
t_u = unify(t_a, t)
if t_u is None:
t = TypeErrorRoot('value type cannot be reconciled with type annotation')
audits(a, 'types', t)
audits(a.target, 'types', t)
else:
t = t_u
audits(a, 'types', t)
audits(a.target, 'types', TypeInParent())
var = a.target.id
env[var] = t
return env
if isinstance(a, ast.Return):
if func:
rules_no_restriction(a)
types(a.value, env, func)
return env
if isinstance(a, ast.For):
rules_no_restriction(a)
types(a.iter, env, func)
t_i = audits(a.iter, 'types')
if isinstance(a.target, ast.Name):
rules_no_restriction(a.target)
var = a.target.id
audits(a.target, 'types', int)
if isinstance(t_i, TypeError):
pass # Allow the error to pass through.
elif t_i == range:
env[var] = int
for a_ in a.body:
env = types(a_, env, func)
else:
audits(a.iter, 'types', TypeErrorRoot('iterable must be a range'))
return env
if isinstance(a, ast.Expr):
types(a.value, env, func)
if not isinstance(audits(a.value, 'rules'), SyntaxRestriction):
rules_no_restriction(a)
return env
# Handle cases in which the input node is an expression.
audits(a, 'types', TypeError('type cannot be determined'))
if isinstance(a, ast.ListComp):
rules_no_restriction(a)
ts = {}
for comprehension in a.generators:
rules_no_restriction(comprehension)
types(comprehension.iter, env, func)
t_c = audits(comprehension.iter, 'types')
if isinstance(comprehension.target, ast.Name):
rules_no_restriction(comprehension.target)
var = comprehension.target.id
t = int
if isinstance(t_c, TypeError):
pass # Allow the error to pass through.
elif t_c == range:
rules_no_restriction(comprehension.iter)
ts[var] = t
else:
t = TypeErrorRoot('iterable must be a range value')
audits(comprehension.iter, 'types', t)
t = typeerror_demote(t)
audits(comprehension.target, 'types', t)
env_ = dict(env)
for (var, t_) in ts.items():
env_[var] = t_
types(a.elt, env_, func)
t_e = audits(a.elt, 'types')
if t_e is not None and not isinstance(t_e, TypeError):
audits(a, 'types', list[t_e])
elif isinstance(a, ast.Call):
ats = []
kts = []
for a_ in a.args:
types(a_, env, func)
ats.append(audits(a_, 'types'))
for a_ in a.keywords:
types(a_.value, env, func)
kts.append(audits(a_.value, 'types'))
if isinstance(a.func, ast.Attribute):
types(a.func.value, env, func)
t_v = audits(a.func.value, 'types')
if a.func.attr == 'if_else':
if len(a.args) == 2:
ts = ats
rules_no_restriction(a)
rules_no_restriction(a.func)
if t_v == Boolean:
if (
ts[0] in (Integer, PublicInteger, SecretInteger) and
ts[1] in (Integer, PublicInteger, SecretInteger)
):
t = max(ts)
else:
t = TypeErrorRoot('branches must have the same integer type')
elif t_v == PublicBoolean:
if (
ts[0] in (Integer, PublicInteger, SecretInteger) and
ts[1] in (Integer, PublicInteger, SecretInteger)
):
t = max(ts)
else:
t = TypeErrorRoot('branches must have the same integer type')
elif t_v == SecretBoolean:
if (
ts[0] in (Integer, PublicInteger, SecretInteger) and
ts[1] in (Integer, PublicInteger, SecretInteger)
):
t = max(ts)
else:
t = TypeErrorRoot('branches must have the same integer type')
else:
t = TypeErrorRoot('condition must have a boolean type')
audits(a, 'types', t)
elif a.func.attr == 'append':
if len(a.args) == 1:
rules_no_restriction(a)
rules_no_restriction(a.func)
t_i = ats[0]
if unify(t_v, list[t_i]):
audits(a, 'types', type(None))
else:
audits(
a,
'types',
TypeErrorRoot('item type does not match list type')
)
elif isinstance(a.func, ast.Name):
if a.func.id == 'Party':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = TypeError('party requires name parameter (a string)')
if (
len(a.args) == 0 and
len(a.keywords) == 1 and
a.keywords[0].arg == 'name'
):
rules_no_restriction(a.keywords[0])
if audits(a.keywords[0].value, 'types') == str:
t = Party
elif (
len(a.args) == 1 and
len(a.keywords) == 0
):
rules_no_restriction(a.args[0])
if audits(a.args[0], 'types') == str:
t = Party
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'Input':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = TypeError('input requires name parameter (a string) and party parameter')
if (
len(a.args) == 2 and
len(a.keywords) == 0
):
rules_no_restriction(a.args[0])
rules_no_restriction(a.args[1])
if (
audits(a.args[0], 'types') == str and
audits(a.args[1], 'types') == Party
):
t = Input
if (
len(a.args) == 1 and
len(a.keywords) == 1 and
a.keywords[0].arg == 'party'
):
rules_no_restriction(a.args[0])
rules_no_restriction(a.keywords[0])
if (
audits(a.args[0], 'types') == str and
audits(a.keywords[0].value, 'types') == Party
):
t = Input
if (
len(a.args) == 0 and
len(a.keywords) == 2 and
a.keywords[0].arg == 'name' and
a.keywords[1].arg == 'party'
):
rules_no_restriction(a.keywords[0])
rules_no_restriction(a.keywords[1])
if (
audits(a.keywords[0].value, 'types') == str and
audits(a.keywords[1].value, 'types') == Party
):
t = Input
if (
len(a.args) == 0 and
len(a.keywords) == 2 and
a.keywords[1].arg == 'name' and
a.keywords[0].arg == 'party'
):
rules_no_restriction(a.keywords[0])
rules_no_restriction(a.keywords[1])
if (
audits(a.keywords[1].value, 'types') == str and
audits(a.keywords[0].value, 'types') == Party
):
t = Input
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'Output':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = TypeError(
'output requires value parameter, name parameter (a string), ' +
'and party parameter'
)
kwargs = {kw.arg: kw.value for kw in a.keywords}
if len(a.args) == 0 and len(a.keywords) == 3:
if set(kwargs.keys()) == {'value', 'name', 'party'}:
for kw in a.keywords:
rules_no_restriction(kw)
if (
(
audits(kwargs['value'], 'types')
in
(SecretInteger, PublicInteger)
) and
audits(kwargs['name'], 'types') == str and
audits(kwargs['party'], 'types') == Party
):
t = Output
elif len(a.args) == 1 and len(a.keywords) == 2:
if set(kwargs.keys()) == {'name', 'party'}:
for arg in a.args:
rules_no_restriction(arg)
for kw in a.keywords:
rules_no_restriction(kw)
if (
(
audits(a.args[0], 'types')
in
(SecretInteger, PublicInteger)
) and
audits(kwargs['name'], 'types') == str and
audits(kwargs['party'], 'types') == Party
):
t = Output
elif len(a.args) == 2 and len(a.keywords) == 1:
if set(kwargs.keys()) == {'party'}:
for arg in a.args:
rules_no_restriction(arg)
for kw in a.keywords:
rules_no_restriction(kw)
if (
(
audits(a.args[0], 'types')
in
(SecretInteger, PublicInteger)
) and
audits(a.args[1], 'types') == str and
audits(kwargs['party'], 'types') == Party
):
t = Output
elif len(a.args) == 3 and len(a.keywords) == 0:
for arg in a.args:
rules_no_restriction(arg)
for kw in a.keywords:
rules_no_restriction(kw)
if (
(
audits(a.args[0], 'types')
in
(SecretInteger, PublicInteger)
) and
audits(a.args[1], 'types') == str and
audits(a.args[2], 'types') == Party
):
t = Output
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'Integer':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = Integer
if (
len(a.args) != 1 or
audits(a.args[0], 'types') != int
):
t = TypeError('expecting single argument (an integer)')
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'PublicInteger':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = PublicInteger
if (
len(a.args) != 1 or
audits(a.args[0], 'types') != Input
):
t = TypeError('expecting single argument (an input object)')
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'SecretInteger':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = SecretInteger
if (
len(a.args) != 1 or
audits(a.args[0], 'types') != Input
):
t = TypeError('expecting single argument (an input object)')
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'range':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = range
if (
len(a.args) != 1 or
audits(a.args[0], 'types') != int
):
t = TypeErrorRoot('expecting single integer argument')
if isinstance(audits(a.args[0], 'types'), TypeError):
t = typeerror_demote(t)
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'str':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = str
if (
len(a.args) != 1 or
audits(a.args[0], 'types') != int
):
t = TypeError('expecting single integer argument')
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id == 'sum':
rules_no_restriction(a)
rules_no_restriction(a.func)
t = SecretInteger
if (
len(a.args) != 1 or
audits(a.args[0], 'types') != list[SecretInteger]
):
t = TypeError('expecting argument of type list[SecretInteger]')
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif a.func.id in env:
rules_no_restriction(a)
rules_no_restriction(a.func)
t_f = env[a.func.id]
ts = t_f.__args__[:-1]
t = TypeErrorRoot('function arguments do not match function type')
if len(ats) == len(ts):
if all(t_a == t for (t_a, t) in zip(ats, ts)):
t = t_f.__args__[-1]
audits(a, 'types', t)
audits(a.func, 'types', TypeInParent())
elif isinstance(a, ast.Subscript):
rules_no_restriction(a)
types(a.value, env, func)
types(a.slice, env, func)
t_v = audits(a.value, 'types')
t_s = audits(a.slice, 'types')
if (not isinstance(t_v, TypeError)) and (not isinstance(t_s, TypeError)):
t = TypeErrorRoot('expecting list value and integer index')
if t_v.__name__ == 'list' and t_s == int:
if hasattr(t_v, '__args__') and len(t_v.__args__) == 1:
t = t_v.__args__[0]
audits(a, 'types', t)
elif isinstance(a, ast.List):
rules_no_restriction(a)
for a_ in a.elts:
types(a_, env, func)
ts = [audits(a_, 'types') for a_ in a.elts]
t = TypeError('lists must contain elements that are all of the same type')
if len(set(ts)) == 0:
t = list
elif len(set(ts)) == 1:
t = ts[0]
if isinstance(t, TypeError):
t = typeerror_demote(t)
else:
t = list[t]
audits(a, 'types', t)
elif isinstance(a, ast.BoolOp):
rules_no_restriction(a)
ts = []
for a_ in a.values:
types(a_, env, func)
ts.append(audits(a_, 'types'))
t = TypeErrorRoot('arguments must be boolean values')
if all(t == bool for t in ts):
t = bool
elif any(isinstance(t, TypeError) for t in ts):
t = typeerror_demote(t)
audits(a, 'types', t)
elif isinstance(a, ast.BinOp):
types(a.left, env, func)
types(a.right, env, func)
t_l = audits(a.left, 'types')
t_r = audits(a.right, 'types')
if isinstance(a.op, ast.Add):
rules_no_restriction(a)
t = TypeError('unsupported operand types')
if t_l == int and t_r == int:
t = int
elif t_l == str and t_r == str:
t = str
else:
t = _types_binop_mult_add_sub(t_l, t_r)
audits(a, 'types', t)
elif isinstance(a.op, ast.Sub):
rules_no_restriction(a)
t = TypeError('unsupported operand types')
if t_l == int and t_r == int:
t = int
else:
t = _types_binop_mult_add_sub(t_l, t_r)
audits(a, 'types', t)
elif isinstance(a.op, ast.Mult):
rules_no_restriction(a)
if t_l == int and t_r == int:
t = int
else:
t = _types_binop_mult_add_sub(t_l, t_r)
audits(a, 'types', t)
elif isinstance(a, ast.Compare):
if len(a.comparators) != 1:
audits(
a,
'rules',
SyntaxRestriction('chained comparisons are prohibited in strict mode')
)
else:
op = a.ops[0]
rules_no_restriction(a)
types(a.left, env, func)
types(a.comparators[0], env, func)
t_l = audits(a.left, 'types')
t_r = audits(a.comparators[0], 'types')
if isinstance(op, (ast.Eq, ast.NotEq)):
if t_l == bool and t_r == bool:
t = bool
elif t_l == int and t_r == int:
t = bool
elif t_l == str and t_r == str:
t = bool
else:
t = _types_compare(t_l, t_r)
elif isinstance(op, (ast.Lt, ast.LtE, ast.Gt, ast.GtE)):
if t_l == int and t_r == int:
t = bool
else:
t = _types_compare(t_l, t_r)
audits(a, 'types', t)
elif isinstance(a, ast.UnaryOp):
types(a.operand, env, func)
t = audits(a.operand, 'types')
if isinstance(a.op, (ast.UAdd, ast.USub)):
rules_no_restriction(a)
if isinstance(t, TypeError):
t = typeerror_demote(t)
elif t in (int, Integer, PublicInteger, SecretInteger):
pass
else:
t = TypeErrorRoot('argument must have an integer type')
audits(a, 'types', t)
elif isinstance(a.op, ast.Not):
rules_no_restriction(a)
if isinstance(t, TypeError):
t = typeerror_demote(t)
elif t == bool:
pass
else:
t = TypeErrorRoot('argument must be a boolean value')
audits(a, 'types', t)
elif isinstance(a, ast.Name):
rules_no_restriction(a)
var = a.id
audits(
a,
'types',
env[var] if var in env else TypeError("name '" + var + "' is not defined")
)
elif isinstance(a, ast.Constant):
if a.value in (False, True) and str(a.value) in ('False', 'True'):
rules_no_restriction(a)
audits(a, 'types', bool)
elif isinstance(a.value, int):
rules_no_restriction(a)
audits(a, 'types', int)
elif isinstance(a.value, str):
rules_no_restriction(a)
audits(a, 'types', str)
return env # Always return the environment.
[docs]
def strict(source: str) -> richreports.report:
"""
Take a Python source file representing a Nada DSL program, statically
analyze it (against the "strict" Nada DSL subset), and generate an
interactive HTML report detailing the results.
"""
source = source.strip()
(atok, skips) = parse(source)
root = atok.tree
# Perform the static analyses.
rules(root)
types(root)
# Perform the abstract execution.
#root.body.append(ast.Expr(ast.Call(ast.Name('nada_main', ast.Load()), [], [])))
#ast.fix_missing_locations(root)
#exec(compile(root, path, 'exec'))
# Add the results of the analyses to the report and ensure each line is
# wrapped as an HTML element.
report = richreports.report(source, line=1, column=0)
enrich_fromaudits(report, atok)
for (i, line) in enumerate(report.lines):
if i in skips:
report.enrich(
(i + 1, 0), (i + 1, len(line) - 1),
'<span class="rules-SyntaxError">', '</span>',
skip_whitespace=True
)
report.enrich(
(i + 1, 0), (i + 1, len(line) - 1),
'<span class="detail" data-detail="SyntaxError">', '</span>',
skip_whitespace=True
)
report.enrich((i + 1, 0), (i + 1, len(line)), '<div>', '</div>')
return report