Skip to content

Commit a6d9887

Browse files
committed
Error messages with source filename and location
1 parent d8977c6 commit a6d9887

10 files changed

+98
-44
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@
99
_build
1010
.benchmarks
1111
.hypothesis
12+
build

src/fluent_compiler/__init__.py

Lines changed: 37 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from collections import OrderedDict
44

5+
import attr
56
import babel
67
import babel.numbers
78
import babel.plural
@@ -11,7 +12,7 @@
1112
from .builtins import BUILTINS
1213
from .compiler import compile_messages
1314
from .errors import FluentDuplicateMessageId, FluentJunkFound
14-
from .utils import ATTRIBUTE_SEPARATOR, TERM_SIGIL, ast_to_id
15+
from .utils import ATTRIBUTE_SEPARATOR, TERM_SIGIL, ast_to_id, display_location, span_to_position
1516

1617

1718
class FluentBundle(object):
@@ -50,27 +51,46 @@ def __init__(self, locale, resources, functions=None, use_isolating=True, escape
5051
def from_string(cls, locale, text, functions=None, use_isolating=True, escapers=None):
5152
return cls(
5253
locale,
53-
[Resource(text)],
54+
[FtlResource(text)],
5455
use_isolating=use_isolating,
5556
functions=functions,
5657
escapers=escapers
5758
)
5859

59-
def _add_resource(self, resource):
60+
@classmethod
61+
def from_files(cls, locale, filenames, functions=None, use_isolating=True, escapers=None):
62+
return cls(
63+
locale,
64+
[FtlResource.from_file(f) for f in filenames],
65+
use_isolating=use_isolating,
66+
functions=functions,
67+
escapers=escapers
68+
)
69+
70+
def _add_resource(self, ftl_resource):
6071
parser = FluentParser()
61-
resource = parser.parse(resource.text)
72+
resource = parser.parse(ftl_resource.text)
6273
for item in resource.body:
6374
if isinstance(item, (Message, Term)):
6475
full_id = ast_to_id(item)
6576
if full_id in self._messages_and_terms:
6677
self._parsing_issues.append((full_id, FluentDuplicateMessageId(
6778
"Additional definition for '{0}' discarded.".format(full_id))))
6879
else:
80+
# Decorate with ftl_resource for better error messages later
81+
item.ftl_resource = ftl_resource
82+
for attribute in item.attributes:
83+
attribute.ftl_resource = ftl_resource
6984
self._messages_and_terms[full_id] = item
7085
elif isinstance(item, Junk):
7186
self._parsing_issues.append(
72-
(None, FluentJunkFound("Junk found: " +
73-
'; '.join(a.message for a in item.annotations),
87+
(None, FluentJunkFound("Junk found:\n" +
88+
'\n'.join(' {0}: {1}'.format(
89+
display_location(
90+
ftl_resource.filename,
91+
span_to_position(a.span, ftl_resource.text)
92+
), a.message)
93+
for a in item.annotations),
7494
item.annotations)))
7595

7696
def has_message(self, message_id):
@@ -95,7 +115,14 @@ def check_messages(self):
95115
return self._parsing_issues + self._compilation_errors
96116

97117

98-
class Resource(object):
99-
def __init__(self, text, name=None):
100-
self.text = text
101-
self.name = name
118+
@attr.s
119+
class FtlResource(object):
120+
'''
121+
Represents an (unparsed) FTL file (contents and optional filename)
122+
'''
123+
text = attr.ib()
124+
filename = attr.ib(default=None)
125+
126+
@classmethod
127+
def from_file(cls, filename):
128+
return cls(text=open(filename).read(), filename=filename)

src/fluent_compiler/compiler.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ class CurrentEnvironment(object):
5959
# The parts of CompilerEnvironment that we want to mutate (and restore)
6060
# temporarily for some parts of a call chain.
6161
message_id = attr.ib(default=None)
62+
ftl_resource = attr.ib(default=None)
6263
term_args = attr.ib(default=None)
6364
in_select_expression = attr.ib(default=False)
6465
escaper = attr.ib(default=null_escaper)
@@ -245,6 +246,7 @@ def get_name_properties(name):
245246
# Pass 2, actual compilation
246247
for msg_id, msg in message_ids_to_ast.items():
247248
with compiler_env.modified(message_id=msg_id,
249+
ftl_resource=msg.ftl_resource,
248250
escaper=compiler_env.escaper_for_message(message_id=msg_id)):
249251
function_name = compiler_env.message_mapping[msg_id]
250252
function = compile_message(msg, msg_id, function_name, module, compiler_env)
@@ -885,11 +887,11 @@ def lookup_term_reference(ref, block, compiler_env):
885887
if ref.attribute:
886888
parent_id = reference_to_id(ref, ignore_attributes=True)
887889
if parent_id in compiler_env.term_ids_to_ast:
888-
error = unknown_reference_error_obj(term_id)
890+
error = unknown_reference_error_obj(compiler_env.current.ftl_resource, ref, term_id)
889891
add_static_msg_error(block, error)
890892
compiler_env.add_current_message_error(error)
891893
return compiler_env.term_ids_to_ast[parent_id], compiler_env.escaper_for_message(parent_id), None
892-
return None, None, unknown_reference(term_id, block, compiler_env)
894+
return None, None, unknown_reference(ref, term_id, block, compiler_env)
893895

894896

895897
def handle_message_reference(ref, block, compiler_env):
@@ -900,11 +902,11 @@ def handle_message_reference(ref, block, compiler_env):
900902
if ref.attribute:
901903
parent_id = reference_to_id(ref, ignore_attributes=True)
902904
if parent_id in compiler_env.message_ids_to_ast:
903-
error = unknown_reference_error_obj(msg_id)
905+
error = unknown_reference_error_obj(compiler_env.current.ftl_resource, ref, msg_id)
904906
add_static_msg_error(block, error)
905907
compiler_env.add_current_message_error(error)
906908
return do_message_call(parent_id, block, compiler_env)
907-
return unknown_reference(msg_id, block, compiler_env)
909+
return unknown_reference(ref, msg_id, block, compiler_env)
908910

909911

910912
def make_fluent_none(name, scope):
@@ -992,8 +994,8 @@ def resolve_select_expression_statically(select_expr, key_ast, block, compiler_e
992994
return compile_expr(found.value, block, compiler_env)
993995

994996

995-
def unknown_reference(name, block, compiler_env):
996-
error = unknown_reference_error_obj(name)
997+
def unknown_reference(ast_node, name, block, compiler_env):
998+
error = unknown_reference_error_obj(compiler_env.current.ftl_resource, ast_node, name)
997999
add_static_msg_error(block, error)
9981000
compiler_env.add_current_message_error(error)
9991001
return make_fluent_none(name, block.scope)

src/fluent_compiler/utils.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -257,9 +257,23 @@ def sanitize_function_args(arg_spec, name, errors):
257257
return (positional_args, cleaned_kwargs)
258258

259259

260-
def unknown_reference_error_obj(ref_id):
260+
def unknown_reference_error_obj(source, ast_node, ref_id):
261+
location = display_location(source.filename, span_to_position(ast_node.span, source.text))
261262
if ATTRIBUTE_SEPARATOR in ref_id:
262-
return FluentReferenceError("Unknown attribute: {0}".format(ref_id))
263+
return FluentReferenceError("{0}: Unknown attribute: {1}".format(location, ref_id))
263264
if ref_id.startswith(TERM_SIGIL):
264-
return FluentReferenceError("Unknown term: {0}".format(ref_id))
265-
return FluentReferenceError("Unknown message: {0}".format(ref_id))
265+
return FluentReferenceError("{0}: Unknown term: {1}".format(location, ref_id))
266+
return FluentReferenceError("{0}: Unknown message: {1}".format(location, ref_id))
267+
268+
269+
def span_to_position(span, source_text):
270+
start = span.start
271+
relevant = source_text[0:start]
272+
row = relevant.count("\n") + 1
273+
col = len(relevant) - relevant.rfind("\n")
274+
return row, col
275+
276+
277+
def display_location(filename, position):
278+
row, col = position
279+
return "{0}:{1}:{2}".format(filename if filename else '<string>', row, col)

tests/format/test_attributes.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,28 +111,28 @@ def test_falls_back_for_msg_with_string_value_and_no_attributes(self):
111111
self.assertEqual(val, 'Foo')
112112
self.assertEqual(errs,
113113
[FluentReferenceError(
114-
'Unknown attribute: foo.missing')])
114+
'<string>:8:13: Unknown attribute: foo.missing')])
115115

116116
def test_falls_back_for_msg_with_string_value_and_other_attributes(self):
117117
val, errs = self.bundle.format('ref-bar', {})
118118
self.assertEqual(val, 'Bar')
119119
self.assertEqual(errs,
120120
[FluentReferenceError(
121-
'Unknown attribute: bar.missing')])
121+
'<string>:9:13: Unknown attribute: bar.missing')])
122122

123123
def test_falls_back_for_msg_with_pattern_value_and_no_attributes(self):
124124
val, errs = self.bundle.format('ref-baz', {})
125125
self.assertEqual(val, 'Foo Baz')
126126
self.assertEqual(errs,
127127
[FluentReferenceError(
128-
'Unknown attribute: baz.missing')])
128+
'<string>:10:13: Unknown attribute: baz.missing')])
129129

130130
def test_falls_back_for_msg_with_pattern_value_and_other_attributes(self):
131131
val, errs = self.bundle.format('ref-qux', {})
132132
self.assertEqual(val, 'Foo Qux')
133133
self.assertEqual(errs,
134134
[FluentReferenceError(
135-
'Unknown attribute: qux.missing')])
135+
'<string>:11:13: Unknown attribute: qux.missing')])
136136

137137
def test_attr_only_main(self):
138138
# For reference, Javascript implementation returns null for this case.
@@ -149,4 +149,4 @@ def test_attr_only_attribute(self):
149149
def test_missing_message_and_attribute(self):
150150
val, errs = self.bundle.format('ref-double-missing', {})
151151
self.assertEqual(val, 'missing.attr')
152-
self.assertEqual(errs, [FluentReferenceError('Unknown attribute: missing.attr')])
152+
self.assertEqual(errs, [FluentReferenceError('<string>:14:24: Unknown attribute: missing.attr')])

tests/format/test_parameterized_terms.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def test_no_implicit_access_to_external_args_but_term_args_still_passed(self):
6464
def test_bad_term(self):
6565
val, errs = self.bundle.format('bad-term', {})
6666
self.assertEqual(val, '-missing')
67-
self.assertEqual(errs, [FluentReferenceError('Unknown term: -missing')])
67+
self.assertEqual(errs, [FluentReferenceError('<string>:12:14: Unknown term: -missing')])
6868

6969

7070
class TestParameterizedTermsWithNumbers(unittest.TestCase):
@@ -137,7 +137,7 @@ def test_missing_attr(self):
137137
# We should fall back to the parent, and still pass the args.
138138
val, errs = self.bundle.format('missing-attr-ref', {})
139139
self.assertEqual(val, 'ABC option')
140-
self.assertEqual(errs, [FluentReferenceError('Unknown attribute: -other.missing')])
140+
self.assertEqual(errs, [FluentReferenceError('<string>:18:22: Unknown attribute: -other.missing')])
141141

142142

143143
class TestNestedParameterizedTerms(unittest.TestCase):
@@ -221,7 +221,7 @@ def test_fallback(self):
221221
def test_term_with_missing_term_reference(self):
222222
val, errs = self.bundle.format('uses-bad-term', {})
223223
self.assertEqual(val, 'Something wrong -missing')
224-
self.assertEqual(errs, [FluentReferenceError('Unknown term: -missing',)])
224+
self.assertEqual(errs, [FluentReferenceError('<string>:13:33: Unknown term: -missing',)])
225225

226226

227227
class TestTermsCalledFromTerms(unittest.TestCase):

tests/format/test_placeables.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,23 +63,23 @@ def test_placeable_bad_message(self):
6363
self.assertEqual(len(errs), 1)
6464
self.assertEqual(
6565
errs,
66-
[FluentReferenceError("Unknown message: not-a-message")])
66+
[FluentReferenceError("<string>:15:26: Unknown message: not-a-message")])
6767

6868
def test_placeable_bad_message_attr(self):
6969
val, errs = self.bundle.format('bad-message-attr-ref', {})
7070
self.assertEqual(val, 'Text Message')
7171
self.assertEqual(len(errs), 1)
7272
self.assertEqual(
7373
errs,
74-
[FluentReferenceError("Unknown attribute: message.not-an-attr")])
74+
[FluentReferenceError("<string>:16:31: Unknown attribute: message.not-an-attr")])
7575

7676
def test_placeable_bad_term(self):
7777
val, errs = self.bundle.format('bad-term-ref', {})
7878
self.assertEqual(val, 'Text -not-a-term')
7979
self.assertEqual(len(errs), 1)
8080
self.assertEqual(
8181
errs,
82-
[FluentReferenceError("Unknown term: -not-a-term")])
82+
[FluentReferenceError("<string>:17:23: Unknown term: -not-a-term")])
8383

8484
def test_cycle_detection(self):
8585
val, errs = self.bundle.format('self-referencing-message', {})

tests/format/test_select_expression.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,4 +311,4 @@ def test_ref_term_attribute_missing(self):
311311
self.assertEqual(val, "Other")
312312
self.assertEqual(len(errs), 1)
313313
self.assertEqual(errs,
314-
[FluentReferenceError('Unknown attribute: -my-term.missing')])
314+
[FluentReferenceError('<string>:15:27: Unknown attribute: -my-term.missing')])

tests/test_bundle.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import unittest
55

6-
from fluent_compiler import FluentBundle
6+
from fluent_compiler import FluentBundle, FtlResource
77
from fluent_compiler.errors import FluentDuplicateMessageId, FluentJunkFound, FluentReferenceError
88

99
from .utils import dedent_ftl
@@ -124,20 +124,28 @@ def test_check_messages_duplicate(self):
124124
self.assertEqual(bundle.format('foo')[0], 'Foo')
125125

126126
def test_check_messages_junk(self):
127-
bundle = FluentBundle.from_string('en-US', "unfinished")
127+
bundle = FluentBundle('en-US', [FtlResource("unfinished", filename='myfile.ftl')])
128128
checks = bundle.check_messages()
129129
self.assertEqual(len(checks), 1)
130130
check1_name, check1_error = checks[0]
131131
self.assertEqual(check1_name, None)
132132
self.assertEqual(type(check1_error), FluentJunkFound)
133-
self.assertEqual(check1_error.message, 'Junk found: Expected token: "="')
133+
self.assertEqual(check1_error.message, 'Junk found:\n myfile.ftl:1:11: Expected token: "="')
134134
self.assertEqual(check1_error.annotations[0].message, 'Expected token: "="')
135135

136136
def test_check_messages_compile_errors(self):
137-
bundle = FluentBundle.from_string('en-US', "foo = { -missing }")
137+
bundle = FluentBundle('en-US', [FtlResource(dedent_ftl('''
138+
foo = { -missing }
139+
.bar = { -missing }
140+
'''), filename='myfile.ftl')])
138141
checks = bundle.check_messages()
139-
self.assertEqual(len(checks), 1)
142+
self.assertEqual(len(checks), 2)
140143
check1_name, check1_error = checks[0]
141144
self.assertEqual(check1_name, 'foo')
142145
self.assertEqual(type(check1_error), FluentReferenceError)
143-
self.assertEqual(check1_error.args[0], 'Unknown term: -missing')
146+
self.assertEqual(check1_error.args[0], 'myfile.ftl:2:9: Unknown term: -missing')
147+
148+
check2_name, check2_error = checks[1]
149+
self.assertEqual(check2_name, 'foo.bar')
150+
self.assertEqual(type(check2_error), FluentReferenceError)
151+
self.assertEqual(check2_error.args[0], 'myfile.ftl:3:14: Unknown term: -missing')

tests/test_compiler.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from markupsafe import Markup, escape
66

7-
from fluent_compiler import FluentBundle
7+
from fluent_compiler import FluentBundle, FtlResource
88
from fluent_compiler.compiler import messages_to_module
99
from fluent_compiler.errors import FluentCyclicReferenceError, FluentFormatError, FluentReferenceError
1010
from fluent_compiler.utils import SimpleNamespace
@@ -17,13 +17,15 @@
1717
# the other FluentBundle.format tests.
1818

1919

20-
def compile_messages_to_python(source, locale, use_isolating=False, functions=None, escapers=None):
20+
def compile_messages_to_python(source, locale, use_isolating=False,
21+
functions=None, escapers=None, filename=None):
2122
# We use FluentBundle partially here, but then switch to
2223
# messages_to_module instead of compile_messages so that we can get the AST
2324
# back instead of a compiled function.
24-
bundle = FluentBundle.from_string(
25+
resource = FtlResource(dedent_ftl(source), filename=filename)
26+
bundle = FluentBundle(
2527
locale,
26-
dedent_ftl(source),
28+
[resource],
2729
use_isolating=use_isolating,
2830
functions=functions,
2931
escapers=escapers,
@@ -153,11 +155,11 @@ def test_single_message_bad_reference(self):
153155
# into the function for the runtime error.
154156
self.assertCodeEqual(code, """
155157
def bar(message_args, errors):
156-
errors.append(FluentReferenceError('Unknown message: foo'))
158+
errors.append(FluentReferenceError('<string>:2:9: Unknown message: foo'))
157159
return 'foo'
158160
""")
159161
# And we should get a compile time error:
160-
self.assertEqual(errs, [('bar', FluentReferenceError("Unknown message: foo"))])
162+
self.assertEqual(errs, [('bar', FluentReferenceError("<string>:2:9: Unknown message: foo"))])
161163

162164
def test_name_collision_function_args(self):
163165
code, errs = compile_messages_to_python("""

0 commit comments

Comments
 (0)