@@ -20,6 +20,32 @@ using loreline.Utf8;
2020#end
2121class 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 */
0 commit comments