Skip to content

Commit 90845c2

Browse files
committed
Catchable parse/loadLocale errors via Loreline.lastError()
1 parent 36f952f commit 90845c2

24 files changed

Lines changed: 376 additions & 61 deletions

File tree

cpp/include/Loreline.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,19 @@ LORELINE_PUBLIC void Loreline_translationFormat(
298298
bool enabled
299299
);
300300

301+
/* Returns the error message from the most recent failed Loreline_parse() or
302+
* Loreline_loadLocale() call, or an empty string when the most recent such
303+
* call succeeded.
304+
*
305+
* Used when a callback variant was supplied: the callback fires with a null
306+
* script/translations to signal failure, and this function tells you what
307+
* went wrong. Without a callback, errors typically surface as exceptions on
308+
* supported targets — this still mirrors the same value so it can be read
309+
* after recovery.
310+
*
311+
* Not thread-safe — read immediately after the call returns. */
312+
LORELINE_PUBLIC Loreline_String Loreline_lastError(void);
313+
301314
/* Interpreter options — configure custom functions, strict access, translations */
302315
LORELINE_PUBLIC Loreline_InterpreterOptions* Loreline_createOptions(void);
303316
LORELINE_PUBLIC void Loreline_releaseOptions(Loreline_InterpreterOptions* options);

cs/Loreline/Loreline.cs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,23 @@ public static void TranslationFormat(string name, bool enabled)
273273
Runtime.Loreline.translationFormat(name, enabled);
274274
}
275275

276+
/// <summary>
277+
/// Returns the error from the most recent failed <see cref="Parse"/> or
278+
/// <see cref="LoadLocale"/> call, or <c>null</c> on success.
279+
/// </summary>
280+
/// <remarks>
281+
/// In async mode (callback supplied) the callback fires with <c>null</c>
282+
/// on failure and this method tells you what went wrong. In sync mode
283+
/// the call throws, and this field is set to the same error so it can
284+
/// be inspected after the catch.
285+
///
286+
/// Not thread-safe — read immediately after the call returns.
287+
/// </remarks>
288+
public static Runtime.Error LastError()
289+
{
290+
return Runtime.Loreline.lastError();
291+
}
292+
276293
/// <summary>
277294
/// Loads translations for a specific locale, walking the script's full import tree.
278295
/// </summary>

js/loreline.d.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,19 @@ export class Loreline {
313313
*/
314314
static translationFormat(name: string, enabled: boolean): void;
315315

316+
/**
317+
* Returns the error from the most recent failed `parse()` or `loadLocale()`
318+
* call, or `null` on success.
319+
*
320+
* In async mode (callback supplied) the callback fires with `null` on
321+
* failure and this method tells you what went wrong. In sync mode the
322+
* call throws, and this is set to the same error so it can be inspected
323+
* after the catch.
324+
*
325+
* Not thread-safe — read immediately after the call returns.
326+
*/
327+
static lastError(): Error | null;
328+
316329
/**
317330
* Prints a parsed script back into Loreline source code.
318331
*

jvm/src/loreline/Loreline.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@ public static void translationFormat(String name, boolean enabled) {
139139
loreline.runtime.Loreline.translationFormat(name, enabled);
140140
}
141141

142+
/**
143+
* Returns the error from the most recent failed {@code parse()} or
144+
* {@code loadLocale()} call, or {@code null} on success.
145+
*
146+
* In async mode (callback supplied) the callback fires with {@code null}
147+
* on failure and this method tells you what went wrong. In sync mode the
148+
* call throws, and this field is set to the same error so it can be
149+
* inspected after the catch.
150+
*
151+
* Not thread-safe — read immediately after the call returns.
152+
*/
153+
public static loreline.runtime.Error lastError() {
154+
return loreline.runtime.Loreline.lastError();
155+
}
156+
142157
/**
143158
* Loads translations for a specific locale.
144159
* Walks the script's full import tree and loads `.<locale>.lor` files for each.

lua/loreline/init.lua

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,16 @@ function M.translation_format(name, enabled)
375375
__loreline_Loreline.translationFormat(name, enabled)
376376
end
377377

378+
--- Return the error from the most recent failed `parse()` or `load_locale()`
379+
-- call, or `nil` on success.
380+
-- In async mode (callback supplied) the callback fires with `nil` on failure
381+
-- and this function tells you what went wrong. In sync mode the call throws,
382+
-- and this is set to the same error so it can be inspected after the catch.
383+
-- Not thread-safe — read immediately after the call returns.
384+
function M.last_error()
385+
return __loreline_Loreline.lastError()
386+
end
387+
378388
--- Load translations for a specific locale, walking the script's full import tree.
379389
-- For each file involved in the script (root + transitively imported), looks up the
380390
-- corresponding translation file by inserting `.<locale>` before the extension

py/loreline/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,20 @@ def translation_format(name: str, enabled: bool) -> None:
500500
"""
501501
_core.loreline_Loreline.translationFormat(name, enabled)
502502

503+
@staticmethod
504+
def last_error() -> Optional[Any]:
505+
"""Return the error from the most recent failed ``parse()`` or
506+
``load_locale()`` call, or ``None`` on success.
507+
508+
In async mode (callback supplied) the callback fires with ``None`` on
509+
failure and this method tells you what went wrong. In sync mode the
510+
call throws, and this field is set to the same error so it can be
511+
inspected after the catch.
512+
513+
Not thread-safe — read immediately after the call returns.
514+
"""
515+
return _core.loreline_Loreline.lastError()
516+
503517
@staticmethod
504518
def load_locale(
505519
locale: str,

src/loreline/Loreline.hx

Lines changed: 110 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,32 @@ using loreline.Utf8;
2020
#end
2121
class Loreline {
2222

23+
/**
24+
* The error produced by the most recent `parse()` or `loadLocale()` call,
25+
* or `null` when the most recent such call succeeded.
26+
*
27+
* Used in async mode (when a `callback` argument is supplied): the
28+
* callback fires with `null` to signal failure, and `lastError()` returns
29+
* the actual error. In sync mode the error is thrown directly and this
30+
* field is still populated so the caller can inspect it after a
31+
* `try/catch`.
32+
*
33+
* Cleared at the very start of every public API call that supports it,
34+
* so a later successful call hides earlier errors.
35+
*
36+
* Not thread-safe — if you call Loreline from multiple threads, read
37+
* `lastError()` immediately after the call returns, before another
38+
* Loreline call on another thread can interleave.
39+
*/
40+
static var _lastError:Null<Error> = null;
41+
42+
/**
43+
* @see _lastError
44+
*/
45+
public static function lastError():Null<Error> {
46+
return _lastError;
47+
}
48+
2349
/**
2450
* Parses the given text input and creates an executable `Script` instance from it.
2551
*
@@ -31,12 +57,17 @@ class Loreline {
3157
* @param handleFile
3258
* (optional) A file handler to read imports. If that handler is asynchronous, then `parse()` method
3359
* will return null and `callback` argument should be used to get the final script
34-
* @param callback If provided, will be called with the resulting script as argument. Mostly useful when reading file imports asynchronously
60+
* @param callback If provided, will be called with the resulting script as argument. Mostly useful when reading file imports asynchronously.
61+
* When a callback is supplied, parse errors are reported by invoking it with `null` and the error becomes
62+
* readable via `Loreline.lastError()` — `parse()` itself never throws in that mode. Without a callback,
63+
* the call throws on error as usual.
3564
* @return The parsed script as an AST `Script` instance (if loaded synchronously)
36-
* @throws loreline.Error If the script contains syntax errors or other parsing issues
65+
* @throws loreline.Error If the script contains syntax errors or other parsing issues (sync mode only)
3766
*/
3867
public static function parse(input:String, ?filePath:String, ?handleFile:ImportsFileHandler, ?callback:(script:Script)->Void):Null<Script> {
3968

69+
_lastError = null;
70+
4071
final lexer = new Lexer(input);
4172
final tokens = lexer.tokenize();
4273

@@ -48,7 +79,7 @@ class Loreline {
4879

4980
final lexerErrors = lexer.getErrors();
5081
if (lexerErrors != null && lexerErrors.length > 0) {
51-
throw lexerErrors[0];
82+
return _reportParseError(lexerErrors[0], callback);
5283
}
5384

5485
var result:Script = null;
@@ -59,10 +90,15 @@ class Loreline {
5990

6091
final imports = new Imports();
6192
imports.resolve(filePath, tokens, handleFile, (error) -> {
62-
throw error;
93+
_reportParseError(error, callback);
6394
},
6495
(hasErrors, resolvedImports) -> {
6596

97+
// If imports.resolve already reported an error via the
98+
// error-callback above, skip the rest — we don't want to
99+
// dispatch a partial parse on top of a missing import.
100+
if (_lastError != null) return;
101+
66102
final parser = new Parser(tokens, {
67103
rootPath: filePath,
68104
path: filePath,
@@ -74,7 +110,9 @@ class Loreline {
74110
final parseErrors = parser.getErrors();
75111

76112
if (parseErrors != null && parseErrors.length > 0) {
77-
throw parseErrors[0];
113+
result = null;
114+
_reportParseError(parseErrors[0], callback);
115+
return;
78116
}
79117

80118
if (callback != null) {
@@ -93,7 +131,7 @@ class Loreline {
93131
final parseErrors = parser.getErrors();
94132

95133
if (parseErrors != null && parseErrors.length > 0) {
96-
throw parseErrors[0];
134+
return _reportParseError(parseErrors[0], callback);
97135
}
98136

99137
if (callback != null) {
@@ -103,6 +141,21 @@ class Loreline {
103141
return result;
104142
}
105143

144+
/**
145+
* Publishes `e` to `_lastError`, then routes:
146+
* - if a callback is provided (async-mode contract): call it with `null`
147+
* and return null. Never throw.
148+
* - otherwise: throw `e` (sync-mode contract).
149+
*/
150+
static function _reportParseError(e:Error, ?callback:(script:Script)->Void):Null<Script> {
151+
_lastError = e;
152+
if (callback != null) {
153+
callback(null);
154+
return null;
155+
}
156+
throw e;
157+
}
158+
106159
/**
107160
* Loads translations for a specific locale, walking the script's full import tree.
108161
*
@@ -126,10 +179,17 @@ class Loreline {
126179
* (translations are all in that directory).
127180
* @param handleFile (optional) File handler for reading translation files
128181
* @param callback (optional) Called with the merged translations map. Required for async file handlers.
129-
* @return The merged translations map (synchronously, when `handleFile` is sync)
182+
* When supplied, errors are routed by calling the callback with `null`; the call never throws and
183+
* `Loreline.lastError()` returns the underlying error (including the file path that failed). Without
184+
* a callback, the call throws on error as usual.
185+
* @return The merged translations map (synchronously, when `handleFile` is sync), or `null` if a translation file
186+
* exists but is invalid. Missing translation files are still skipped silently — `lastError()` is only set
187+
* when a file is present but can't be parsed (broken `.lor`, malformed `.po`/`.xliff`/`.csv`, etc.).
130188
*/
131189
public static function loadLocale(locale:String, script:Script, ?filePath:String, ?handleFile:ImportsFileHandler, ?callback:(translations:Map<String, NStringLiteral>)->Void):Null<Map<String, NStringLiteral>> {
132190

191+
_lastError = null;
192+
133193
if (filePath == null) {
134194
filePath = script.filePath;
135195
}
@@ -141,11 +201,15 @@ class Loreline {
141201
}
142202

143203
// Wrap to enable alternate translation file formats (PO, XLIFF, CSV/TSV).
144-
// Pass-through if no format was enabled via Loreline.translationFormat();
145-
// otherwise the wrap tries each enabled format as a sibling and converts
146-
// the first match to a synthesized .lor translation body before handing
147-
// it back to loadLocale.
148-
handleFile = loreline.translation.TranslationFormats.wrap(handleFile, locale);
204+
// Pass-through (with a null lastError) if no format was enabled via
205+
// Loreline.translationFormat(); otherwise the wrap tries each enabled
206+
// format as a sibling and converts the first match to a synthesized
207+
// .lor translation body. Converter errors are captured into
208+
// `wrappedLastError()` and promoted to `_lastError` after the dispatch
209+
// loop below.
210+
final wrapped = loreline.translation.TranslationFormats.wrap(handleFile, locale);
211+
handleFile = wrapped.handler;
212+
final wrappedLastError = wrapped.lastError;
149213

150214
// Determine which Loreline extension we're working with for translation files.
151215
// By default, always ".lor" — ".lor.txt" translations are only considered when
@@ -207,11 +271,26 @@ class Loreline {
207271
final result:Map<String, NStringLiteral> = new Map();
208272
var pending = relativePaths.length;
209273
var allProcessed = false;
274+
// First parse error (own or via the wrapper) encountered during the
275+
// dispatch loop. Promoted to `_lastError` at the end so we know
276+
// exactly which file failed.
277+
var firstParseError:Null<Error> = null;
210278

211279
// No-op file handler for translation files (they shouldn't import anything).
212280
final noopHandle:ImportsFileHandler = (path, cb) -> cb(null);
213281

214-
inline function dispatchResult() {
282+
function dispatchResult() {
283+
// Determine if anything went wrong: a per-file parse error wins,
284+
// otherwise check the wrapper's captured converter error.
285+
final finalError = firstParseError != null ? firstParseError : wrappedLastError();
286+
if (finalError != null) {
287+
_lastError = finalError;
288+
if (callback != null) {
289+
callback(null);
290+
return;
291+
}
292+
throw finalError;
293+
}
215294
if (callback != null) callback(result);
216295
}
217296

@@ -222,6 +301,8 @@ class Loreline {
222301
pending--;
223302
if (content != null) {
224303
try {
304+
// Use a local handle that bypasses the callback path
305+
// of `parse()` so any errors throw synchronously here.
225306
final transScript = parse(content, transPath, noopHandle);
226307
if (transScript != null) {
227308
final translations = AstUtils.extractTranslations(transScript);
@@ -234,9 +315,17 @@ class Loreline {
234315
result.set(relPath + '#' + id, str);
235316
}
236317
}
318+
} catch (e:Error) {
319+
// Translation file parse error: remember the first one
320+
// (with file context) and keep going so other files
321+
// still get tried; final routing happens in dispatchResult.
322+
if (firstParseError == null) {
323+
firstParseError = new Error('Failed to load translation file "$transPath": ' + e.message);
324+
}
237325
} catch (e:Any) {
238-
// Translation file parse error: skip this file silently to keep
239-
// the loadLocale resilient. The caller still gets whatever loaded.
326+
if (firstParseError == null) {
327+
firstParseError = new Error('Failed to load translation file "$transPath": ' + Std.string(e));
328+
}
240329
}
241330
}
242331
if (pending == 0 && allProcessed) {
@@ -250,7 +339,7 @@ class Loreline {
250339
dispatchResult();
251340
}
252341

253-
return result;
342+
return _lastError != null ? null : result;
254343
}
255344

256345
/**
@@ -380,6 +469,11 @@ class Loreline {
380469
*
381470
* Unknown names are accepted silently (forward-compat for future formats).
382471
*
472+
* Malformed files in an enabled format (e.g. broken XML in a `.xliff`,
473+
* a `.po` with an unterminated quoted string) surface as `loreline.Error`
474+
* out of `loadLocale` — caught via try/catch in sync mode, or via
475+
* `Loreline.lastError()` after a callback fires with `null` in async mode.
476+
*
383477
* @param name The format identifier (see above)
384478
* @param enabled True to enable the format, false to disable
385479
*/

src/loreline/lib/linc/linc_Loreline.cpp

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,6 +1262,28 @@ LORELINE_PUBLIC void Loreline_translationFormat(
12621262
LORELINE_END_CALL
12631263
}
12641264

1265+
/* ── Last error ─────────────────────────────────────────────────────────── */
1266+
1267+
static LORELINE_NOINLINE void Loreline_lastError_hx(Loreline_String* out) {
1268+
LORELINE_HX_BEGIN
1269+
::Dynamic err = ::loreline::Loreline_obj::lastError();
1270+
if (err != null()) {
1271+
::String msg = err->__Field(HX_CSTRING("message"), ::hx::paccDynamic);
1272+
if (msg != null()) {
1273+
*out = linc_hxToString(msg);
1274+
}
1275+
}
1276+
LORELINE_HX_END
1277+
}
1278+
1279+
LORELINE_PUBLIC Loreline_String Loreline_lastError(void) {
1280+
Loreline_String out;
1281+
LORELINE_BEGIN_CALL_SYNC
1282+
Loreline_lastError_hx(&out);
1283+
LORELINE_END_CALL
1284+
return out;
1285+
}
1286+
12651287
/* ── Play ───────────────────────────────────────────────────────────────── */
12661288

12671289
static LORELINE_NOINLINE void Loreline_play_hx(

0 commit comments

Comments
 (0)