@halleyassist/rule-parser 1.0.13 → 1.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@halleyassist/rule-parser",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "The grammar for HalleyAssist rules",
5
5
  "main": "src/RuleParser.production.js",
6
6
  "scripts": {
7
- "test": "mocha"
7
+ "test": "mocha",
8
+ "build": "node ./bin/package.js"
8
9
  },
9
10
  "repository": {
10
11
  "type": "git",
@@ -30,7 +30,7 @@ END_ARRAY ::= WS* #x5D WS* /* ] right square bracket */
30
30
  END_OBJECT ::= WS* #x7D WS* /* } right curly bracket */
31
31
  NAME_SEPARATOR ::= WS* #x3A WS* /* : colon */
32
32
  VALUE_SEPARATOR ::= WS* #x2C WS* /* , comma */
33
- WS ::= [#x20#x09#x0A#x0D]+ /* Space | Tab | \n | \r */
33
+ WS ::= [#x20#x09#x0A#x0D] /* Space | Tab | \n | \r */
34
34
 
35
35
  operator ::= GTE | LTE | GT | LT | EQ | NEQ
36
36
  eq_operator ::= EQ | NEQ
@@ -41,8 +41,8 @@ END_ARGUMENT ::= ")"
41
41
  BEGIN_PARENTHESIS ::= "("
42
42
  END_PARENTHESIS ::= ")"
43
43
 
44
- argument ::= statement WS* ("," WS*)?
45
- arguments ::= argument*
44
+ argument ::= statement WS*
45
+ arguments ::= (argument (WS* "," WS* argument)*)?
46
46
  fname ::= [a-zA-z0-9]+
47
47
  fcall ::= fname WS* BEGIN_ARGUMENT WS* arguments? END_ARGUMENT
48
48
 
package/src/RuleParser.js CHANGED
@@ -1,9 +1,12 @@
1
1
  const {Parser} = require('ebnf/dist/Parser.js'),
2
+ {ParsingError} = require('ebnf'),
2
3
  assert = require('assert')
3
4
 
4
5
  let ParserRules = require('./RuleParser.ebnf.js')
5
6
  let ParserCache;
6
7
 
8
+ const { ErrorAnalyzer } = require('./errors/ErrorAnalyzer');
9
+
7
10
  const ArithmeticOperators = {
8
11
  "+": 'MathAdd',
9
12
  "-": 'MathSub',
@@ -68,11 +71,22 @@ class RuleParser {
68
71
  ParserCache = new Parser(ParserRules, {debug: false})
69
72
  }
70
73
 
71
- ret = ParserCache.getAST(txt.trim(), 'statement_main');
74
+ try {
75
+ ret = ParserCache.getAST(txt.trim(), 'statement_main');
76
+ } catch (e) {
77
+ // If ebnf throws ParsingError, convert it to RuleParseError with helpful error code
78
+ if (e instanceof ParsingError) {
79
+ throw ErrorAnalyzer.analyzeParseFailure(txt, e);
80
+ }
81
+ throw e;
82
+ }
72
83
 
73
84
  if(ret){
74
85
  return ret.children[0]
75
86
  }
87
+
88
+ // If parsing failed without throwing (shouldn't happen with new ebnf), throw error
89
+ throw ErrorAnalyzer.analyzeParseFailure(txt);
76
90
  }
77
91
  static _parseArgument(argument){
78
92
  assert(argument.type === 'argument')
@@ -498,9 +512,72 @@ class RuleParser {
498
512
  }
499
513
  }
500
514
  static toIL(txt){
501
- const ast = RuleParser.toAst(txt)
502
- if(!ast) throw new Error(`failed to parse ${txt}`)
503
- return RuleParser._buildExpressionGroup(ast)
515
+ try {
516
+ const ast = RuleParser.toAst(txt)
517
+ if(!ast) throw new Error(`failed to parse ${txt}`)
518
+ return RuleParser._buildExpressionGroup(ast)
519
+ } catch (e) {
520
+ // If it's already a RuleParseError, just re-throw it
521
+ if (e.name === 'RuleParseError') {
522
+ throw e;
523
+ }
524
+
525
+ // Check if it's a validation error we can map to a specific code
526
+ if (e.message && e.message.includes('Invalid time of day')) {
527
+ // Extract the invalid time from the error message
528
+ const match = e.message.match(/Invalid time of day[,:]?\s*([0-9:]+)/);
529
+ const badTod = match ? match[1] : 'invalid';
530
+ const { ParsingError } = require('ebnf');
531
+ const { RuleParseError } = require('./errors/RuleParseError');
532
+
533
+ // Calculate position (simplified - at end of input)
534
+ const lines = txt.trim().split('\n');
535
+ const position = {
536
+ line: lines.length,
537
+ column: lines[lines.length - 1].length + 1,
538
+ offset: txt.trim().length
539
+ };
540
+
541
+ throw new RuleParseError(
542
+ "BAD_TOD",
543
+ `Invalid time of day: ${badTod}`,
544
+ "Time of day must be in HH:MM format with hours 0-23 and minutes 0-59, e.g. 08:30, 14:00, 23:59.",
545
+ position,
546
+ badTod,
547
+ ["HH:MM"],
548
+ txt.trim().substring(Math.max(0, txt.trim().length - 50))
549
+ );
550
+ }
551
+
552
+ // Check if it's a day of week error
553
+ if (e.message && e.message.includes('Invalid day of week')) {
554
+ const match = e.message.match(/Invalid day of week[,:]?\s*(\w+)/);
555
+ const badDow = match ? match[1] : 'invalid';
556
+ const { RuleParseError } = require('./errors/RuleParseError');
557
+
558
+ const lines = txt.trim().split('\n');
559
+ const position = {
560
+ line: lines.length,
561
+ column: lines[lines.length - 1].length + 1,
562
+ offset: txt.trim().length
563
+ };
564
+
565
+ throw new RuleParseError(
566
+ "BAD_DOW",
567
+ `Invalid day of week: ${badDow}`,
568
+ "Valid days are: MONDAY/MON, TUESDAY/TUE, WEDNESDAY/WED, THURSDAY/THU, FRIDAY/FRI, SATURDAY/SAT, SUNDAY/SUN.",
569
+ position,
570
+ badDow,
571
+ ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"],
572
+ txt.trim().substring(Math.max(0, txt.trim().length - 50))
573
+ );
574
+ }
575
+
576
+ // For other errors, re-throw
577
+ throw e;
578
+ }
504
579
  }
505
580
  }
506
581
  module.exports = RuleParser
582
+ module.exports.ParsingError = require('ebnf').ParsingError
583
+ module.exports.RuleParseError = require('./errors/RuleParseError').RuleParseError
@@ -1 +1 @@
1
- module.exports=[{"name":"statement_main","bnf":[["statement","EOF"]]},{"name":"logical_operator","bnf":[["AND"],["OR"]]},{"name":"%statement0","bnf":[["logical_operator","expression"]],"fragment":true},{"name":"statement","bnf":[["expression","%statement0*"]]},{"name":"expression","bnf":[["not_expression"],["standard_expression"],["parenthesis_expression"]]},{"name":"parenthesis_expression","bnf":[["BEGIN_PARENTHESIS","WS*","statement","WS*","END_PARENTHESIS"]]},{"name":"%not_expression1","bnf":[["result"],["parenthesis_expression"]],"fragment":true},{"name":"not_expression","bnf":[["NOT","%not_expression1"]]},{"name":"%%standard_expression23","bnf":[["WS*","eq_approx"]],"fragment":true},{"name":"%%standard_expression24","bnf":[["WS*","basic_rhs"]],"fragment":true},{"name":"%%%standard_expression256","bnf":[["WS+","IS"]],"fragment":true},{"name":"%%standard_expression25","bnf":[["%%%standard_expression256?","WS+","between"]],"fragment":true},{"name":"%standard_expression2","bnf":[["%%standard_expression23"],["%%standard_expression24"],["%%standard_expression25"]],"fragment":true},{"name":"standard_expression","bnf":[["result","%standard_expression2?"]]},{"name":"basic_rhs","bnf":[["operator","WS*","result"]]},{"name":"eq_approx","bnf":[["eq_operator","WS*","\"~\"","WS*","result"]]},{"name":"PLUS","bnf":[["\"+\""]]},{"name":"MINUS","bnf":[["\"-\""]]},{"name":"MULTIPLY","bnf":[["\"*\""]]},{"name":"DIVIDE","bnf":[["\"/\""]]},{"name":"MODULUS","bnf":[["\"%\""]]},{"name":"DEFAULT_VAL","bnf":[["\"??\""]]},{"name":"arithmetic_operator","bnf":[["PLUS"],["MINUS"],["MULTIPLY"],["DIVIDE"],["MODULUS"],["DEFAULT_VAL"]]},{"name":"arithmetic_operand","bnf":[["fcall"],["number_time"],["number"]]},{"name":"%arithmetic_result7","bnf":[["arithmetic_result"],["arithmetic_operand"]],"fragment":true},{"name":"arithmetic_result","bnf":[["arithmetic_operand","WS*","arithmetic_operator","WS*","%arithmetic_result7"]]},{"name":"simple_result","bnf":[["fcall"],["value"]]},{"name":"result","bnf":[["arithmetic_result"],["simple_result"]]},{"name":"value","bnf":[["false"],["true"],["array"],["time_period"],["number_time"],["number"],["number_tod"],["string"]]},{"name":"BEGIN_ARRAY","bnf":[["WS*",/\x5B/,"WS*"]]},{"name":"BEGIN_OBJECT","bnf":[["WS*",/\x7B/,"WS*"]]},{"name":"END_ARRAY","bnf":[["WS*",/\x5D/,"WS*"]]},{"name":"END_OBJECT","bnf":[["WS*",/\x7D/,"WS*"]]},{"name":"NAME_SEPARATOR","bnf":[["WS*",/\x3A/,"WS*"]]},{"name":"VALUE_SEPARATOR","bnf":[["WS*",/\x2C/,"WS*"]]},{"name":"%WS8","bnf":[[/[\x20\x09\x0A\x0D]/]]},{"name":"WS","bnf":[["%WS8+"]]},{"name":"operator","bnf":[["GTE"],["LTE"],["GT"],["LT"],["EQ"],["NEQ"]]},{"name":"eq_operator","bnf":[["EQ"],["NEQ"]]},{"name":"BEGIN_ARGUMENT","bnf":[["\"(\""]]},{"name":"END_ARGUMENT","bnf":[["\")\""]]},{"name":"BEGIN_PARENTHESIS","bnf":[["\"(\""]]},{"name":"END_PARENTHESIS","bnf":[["\")\""]]},{"name":"%argument9","bnf":[["\",\"","WS*"]],"fragment":true},{"name":"argument","bnf":[["statement","WS*","%argument9?"]]},{"name":"arguments","bnf":[["argument*"]]},{"name":"%fname10","bnf":[[/[a-zA-z0-9]/]]},{"name":"fname","bnf":[["%fname10+"]]},{"name":"fcall","bnf":[["fname","WS*","BEGIN_ARGUMENT","WS*","arguments?","END_ARGUMENT"]]},{"name":"%between_number11","bnf":[["number_time"],["number"]],"fragment":true},{"name":"%%between_number1213","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"%%between_number1214","bnf":[["WS*",/\-/,"WS*"]],"fragment":true},{"name":"%between_number12","bnf":[["%%between_number1213"],["%%between_number1214"]],"fragment":true},{"name":"%between_number15","bnf":[["number_time"],["number"]],"fragment":true},{"name":"between_number","bnf":[["%between_number11","%between_number12","%between_number15"]]},{"name":"%%between_number_time1617","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"%%between_number_time1618","bnf":[["WS*",/\-/,"WS*"]],"fragment":true},{"name":"%between_number_time16","bnf":[["%%between_number_time1617"],["%%between_number_time1618"]],"fragment":true},{"name":"%between_number_time19","bnf":[["WS+","dow_range"]],"fragment":true},{"name":"between_number_time","bnf":[["number_time","%between_number_time16","number_time","%between_number_time19?"]]},{"name":"%%between_tod2021","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"%between_tod20","bnf":[["%%between_tod2021"]],"fragment":true},{"name":"%between_tod22","bnf":[["WS+","dow_range"]],"fragment":true},{"name":"between_tod","bnf":[["number_tod","%between_tod20","number_tod","%between_tod22?"]]},{"name":"%between23","bnf":[["between_number"],["between_tod"]],"fragment":true},{"name":"between","bnf":[[/[Bb]/,/[Ee]/,/[Tt]/,/[Ww]/,/[Ee]/,/[Ee]/,/[Nn]/,"WS+","%between23"]]},{"name":"dow","bnf":[[/[Mm]/,/[Oo]/,/[Nn]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Mm]/,/[Oo]/,/[Nn]/],[/[Tt]/,/[Uu]/,/[Ee]/,/[Ss]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Tt]/,/[Uu]/,/[Ee]/],[/[Ww]/,/[Ee]/,/[Dd]/,/[Nn]/,/[Ee]/,/[Ss]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ww]/,/[Ee]/,/[Dd]/],[/[Tt]/,/[Hh]/,/[Uu]/,/[Rr]/,/[Ss]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Tt]/,/[Hh]/,/[Uu]/],[/[Tt]/,/[Hh]/,/[Uu]/,/[Rr]/],[/[Ff]/,/[Rr]/,/[Ii]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ff]/,/[Rr]/,/[Ii]/],[/[Ss]/,/[Aa]/,/[Tt]/,/[Uu]/,/[Rr]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ss]/,/[Aa]/,/[Tt]/],[/[Ss]/,/[Uu]/,/[Nn]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ss]/,/[Uu]/,/[Nn]/]]},{"name":"%dow_range24","bnf":[["WS+",/[Tt]/,/[Oo]/,"WS+","dow"]],"fragment":true},{"name":"dow_range","bnf":[[/[Oo]/,/[Nn]/,"WS+","dow","%dow_range24?"]]},{"name":"between_time_only","bnf":[[/[Bb]/,/[Ee]/,/[Tt]/,/[Ww]/,/[Ee]/,/[Ee]/,/[Nn]/,"WS+","between_number_time"]]},{"name":"between_tod_only","bnf":[[/[Bb]/,/[Ee]/,/[Tt]/,/[Ww]/,/[Ee]/,/[Ee]/,/[Nn]/,"WS+","between_tod"]]},{"name":"%AND25","bnf":[["WS*",/&/,/&/,"WS*"]],"fragment":true},{"name":"%AND26","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"AND","bnf":[["%AND25"],["%AND26"]]},{"name":"%OR27","bnf":[["WS*",/\|/,/\|/,"WS*"]],"fragment":true},{"name":"%OR28","bnf":[["WS+",/[Oo]/,/[Rr]/,"WS+"]],"fragment":true},{"name":"OR","bnf":[["%OR27"],["%OR28"]]},{"name":"AGO","bnf":[[/[Aa]/,/[Gg]/,/[Oo]/]]},{"name":"GT","bnf":[["\">\""]]},{"name":"LT","bnf":[["\"<\""]]},{"name":"GTE","bnf":[["\">=\""]]},{"name":"LTE","bnf":[["\"<=\""]]},{"name":"IS","bnf":[[/[Ii]/,/[Ss]/]]},{"name":"EQ","bnf":[["\"==\""],["\"=\""]]},{"name":"NEQ","bnf":[["\"!=\""]]},{"name":"%NOT29","bnf":[[/!/,"WS*"]],"fragment":true},{"name":"%NOT30","bnf":[[/[Nn]/,/[Oo]/,/[Tt]/,"WS+"]],"fragment":true},{"name":"NOT","bnf":[["%NOT29"],["%NOT30"]]},{"name":"false","bnf":[[/[Ff]/,/[Aa]/,/[Ll]/,/[Ss]/,/[Ee]/]]},{"name":"null","bnf":[[/[Nn]/,/[Uu]/,/[Ll]/,/[Ll]/]]},{"name":"true","bnf":[[/[Tt]/,/[Rr]/,/[Uu]/,/[Ee]/]]},{"name":"%%array3132","bnf":[["VALUE_SEPARATOR","value"]],"fragment":true},{"name":"%array31","bnf":[["value","%%array3132*"]],"fragment":true},{"name":"array","bnf":[["BEGIN_ARRAY","%array31?","END_ARRAY"]]},{"name":"unit","bnf":[[/[Ss]/,/[Ee]/,/[Cc]/,/[Oo]/,/[Nn]/,/[Dd]/,/[Ss]/],[/[Mm]/,/[Ii]/,/[Nn]/,/[Uu]/,/[Tt]/,/[Ee]/,/[Ss]/],[/[Hh]/,/[Oo]/,/[Uu]/,/[Rr]/,/[Ss]/],[/[Ww]/,/[Ee]/,/[Ee]/,/[Kk]/,/[Ss]/],[/[Dd]/,/[Aa]/,/[Yy]/,/[Ss]/],[/[Ss]/,/[Ee]/,/[Cc]/,/[Oo]/,/[Nn]/,/[Dd]/],[/[Mm]/,/[Ii]/,/[Nn]/,/[Uu]/,/[Tt]/,/[Ee]/],[/[Ww]/,/[Ee]/,/[Ee]/,/[Kk]/],[/[Hh]/,/[Oo]/,/[Uu]/,/[Rr]/],[/[Dd]/,/[Aa]/,/[Yy]/],[/[Mm]/,/[Ii]/,/[Nn]/,/[Ss]/],[/[Mm]/,/[Ii]/,/[Nn]/]]},{"name":"%%number3334","bnf":[[/[0-9]/]]},{"name":"%number33","bnf":[["%%number3334+"]],"fragment":true},{"name":"%%number3536","bnf":[[/[0-9]/]]},{"name":"%number35","bnf":[["\".\"","%%number3536+"]],"fragment":true},{"name":"%%number3738","bnf":[["\"-\""],["\"+\""]],"fragment":true},{"name":"%%%number373940","bnf":[[/[0-9]/]]},{"name":"%%number3739","bnf":[["\"0\""],[/[1-9]/,"%%%number373940*"]],"fragment":true},{"name":"%number37","bnf":[["\"e\"","%%number3738?","%%number3739"]],"fragment":true},{"name":"number","bnf":[["\"-\"?","%number33","%number35?","%number37?"]]},{"name":"number_time","bnf":[["number","WS+","unit"]]},{"name":"%%number_tod4142","bnf":[[/[0-9]/]]},{"name":"%number_tod41","bnf":[["%%number_tod4142+"]],"fragment":true},{"name":"%%number_tod4344","bnf":[[/[0-9]/]]},{"name":"%number_tod43","bnf":[["%%number_tod4344+"]],"fragment":true},{"name":"number_tod","bnf":[["%number_tod41","\":\"","%number_tod43"]]},{"name":"%time_period_ago45","bnf":[["WS+","number_time"]],"fragment":true},{"name":"time_period_ago","bnf":[["number_time","%time_period_ago45*","WS+","AGO"]]},{"name":"%time_period_ago_between46","bnf":[["WS+","number_time"]],"fragment":true},{"name":"time_period_ago_between","bnf":[["number_time","%time_period_ago_between46*","WS+","AGO","WS+","between_tod_only"]]},{"name":"time_period_const","bnf":[[/[Tt]/,/[Oo]/,/[Dd]/,/[Aa]/,/[Yy]/],["time_period_ago"]]},{"name":"time_period","bnf":[["time_period_ago_between"],["time_period_const"],["between_tod_only"],["between_time_only"]]},{"name":"%%string4748","bnf":[[/[\x20-\x21]/],[/[\x23-\x5B]/],[/[\x5D-\uFFFF]/]],"fragment":true},{"name":"%%string4749","bnf":[[/\x22/],[/\x5C/],[/\x2F/],[/\x62/],[/\x66/],[/\x6E/],[/\x72/],[/\x74/],[/\x75/,"HEXDIG","HEXDIG","HEXDIG","HEXDIG"]],"fragment":true},{"name":"%string47","bnf":[["%%string4748"],[/\x5C/,"%%string4749"]],"fragment":true},{"name":"string","bnf":[["'\"'","%string47*","'\"'"]]},{"name":"HEXDIG","bnf":[[/[a-fA-F0-9]/]]}]
1
+ module.exports=[{"name":"statement_main","bnf":[["statement","EOF"]]},{"name":"logical_operator","bnf":[["AND"],["OR"]]},{"name":"%statement[2]","bnf":[["logical_operator","expression"]],"fragment":true},{"name":"statement","bnf":[["expression","%statement[2]*"]]},{"name":"expression","bnf":[["not_expression"],["standard_expression"],["parenthesis_expression"]]},{"name":"parenthesis_expression","bnf":[["BEGIN_PARENTHESIS","WS*","statement","WS*","END_PARENTHESIS"]]},{"name":"%not_expression[2]","bnf":[["result"],["parenthesis_expression"]],"fragment":true},{"name":"not_expression","bnf":[["NOT","%not_expression[2]"]]},{"name":"%standard_expression[2][1]","bnf":[["WS*","eq_approx"]],"fragment":true},{"name":"%standard_expression[2][2]","bnf":[["WS*","basic_rhs"]],"fragment":true},{"name":"%standard_expression[2][3][1]","bnf":[["WS+","IS"]],"fragment":true},{"name":"%standard_expression[2][3]","bnf":[["%standard_expression[2][3][1]?","WS+","between"]],"fragment":true},{"name":"%standard_expression[2]","bnf":[["%standard_expression[2][1]"],["%standard_expression[2][2]"],["%standard_expression[2][3]"]],"fragment":true},{"name":"standard_expression","bnf":[["result","%standard_expression[2]?"]]},{"name":"basic_rhs","bnf":[["operator","WS*","result"]]},{"name":"eq_approx","bnf":[["eq_operator","WS*","\"~\"","WS*","result"]]},{"name":"PLUS","bnf":[["\"+\""]]},{"name":"MINUS","bnf":[["\"-\""]]},{"name":"MULTIPLY","bnf":[["\"*\""]]},{"name":"DIVIDE","bnf":[["\"/\""]]},{"name":"MODULUS","bnf":[["\"%\""]]},{"name":"DEFAULT_VAL","bnf":[["\"??\""]]},{"name":"arithmetic_operator","bnf":[["PLUS"],["MINUS"],["MULTIPLY"],["DIVIDE"],["MODULUS"],["DEFAULT_VAL"]]},{"name":"arithmetic_operand","bnf":[["fcall"],["number_time"],["number"]]},{"name":"%arithmetic_result[5]","bnf":[["arithmetic_result"],["arithmetic_operand"]],"fragment":true},{"name":"arithmetic_result","bnf":[["arithmetic_operand","WS*","arithmetic_operator","WS*","%arithmetic_result[5]"]]},{"name":"simple_result","bnf":[["fcall"],["value"]]},{"name":"result","bnf":[["arithmetic_result"],["simple_result"]]},{"name":"value","bnf":[["false"],["true"],["array"],["time_period"],["number_time"],["number"],["number_tod"],["string"]]},{"name":"BEGIN_ARRAY","bnf":[["WS*",/\x5B/,"WS*"]]},{"name":"BEGIN_OBJECT","bnf":[["WS*",/\x7B/,"WS*"]]},{"name":"END_ARRAY","bnf":[["WS*",/\x5D/,"WS*"]]},{"name":"END_OBJECT","bnf":[["WS*",/\x7D/,"WS*"]]},{"name":"NAME_SEPARATOR","bnf":[["WS*",/\x3A/,"WS*"]]},{"name":"VALUE_SEPARATOR","bnf":[["WS*",/\x2C/,"WS*"]]},{"name":"WS","bnf":[[/[\x20\x09\x0A\x0D]/]]},{"name":"operator","bnf":[["GTE"],["LTE"],["GT"],["LT"],["EQ"],["NEQ"]]},{"name":"eq_operator","bnf":[["EQ"],["NEQ"]]},{"name":"BEGIN_ARGUMENT","bnf":[["\"(\""]]},{"name":"END_ARGUMENT","bnf":[["\")\""]]},{"name":"BEGIN_PARENTHESIS","bnf":[["\"(\""]]},{"name":"END_PARENTHESIS","bnf":[["\")\""]]},{"name":"argument","bnf":[["statement","WS*"]]},{"name":"%arguments[1][2]","bnf":[["WS*","\",\"","WS*","argument"]],"fragment":true},{"name":"%arguments[1]","bnf":[["argument","%arguments[1][2]*"]],"fragment":true},{"name":"arguments","bnf":[["%arguments[1]?"]]},{"name":"%fname[1]","bnf":[[/[a-zA-z0-9]/]]},{"name":"fname","bnf":[["%fname[1]+"]]},{"name":"fcall","bnf":[["fname","WS*","BEGIN_ARGUMENT","WS*","arguments?","END_ARGUMENT"]]},{"name":"%between_number[1]","bnf":[["number_time"],["number"]],"fragment":true},{"name":"%between_number[2][1]","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"%between_number[2][2]","bnf":[["WS*",/\-/,"WS*"]],"fragment":true},{"name":"%between_number[2]","bnf":[["%between_number[2][1]"],["%between_number[2][2]"]],"fragment":true},{"name":"%between_number[3]","bnf":[["number_time"],["number"]],"fragment":true},{"name":"between_number","bnf":[["%between_number[1]","%between_number[2]","%between_number[3]"]]},{"name":"%between_number_time[2][1]","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"%between_number_time[2][2]","bnf":[["WS*",/\-/,"WS*"]],"fragment":true},{"name":"%between_number_time[2]","bnf":[["%between_number_time[2][1]"],["%between_number_time[2][2]"]],"fragment":true},{"name":"%between_number_time[4]","bnf":[["WS+","dow_range"]],"fragment":true},{"name":"between_number_time","bnf":[["number_time","%between_number_time[2]","number_time","%between_number_time[4]?"]]},{"name":"%between_tod[2][1]","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"%between_tod[2]","bnf":[["%between_tod[2][1]"]],"fragment":true},{"name":"%between_tod[4]","bnf":[["WS+","dow_range"]],"fragment":true},{"name":"between_tod","bnf":[["number_tod","%between_tod[2]","number_tod","%between_tod[4]?"]]},{"name":"%between[3]","bnf":[["between_number"],["between_tod"]],"fragment":true},{"name":"between","bnf":[[/[Bb]/,/[Ee]/,/[Tt]/,/[Ww]/,/[Ee]/,/[Ee]/,/[Nn]/,"WS+","%between[3]"]]},{"name":"dow","bnf":[[/[Mm]/,/[Oo]/,/[Nn]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Mm]/,/[Oo]/,/[Nn]/],[/[Tt]/,/[Uu]/,/[Ee]/,/[Ss]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Tt]/,/[Uu]/,/[Ee]/],[/[Ww]/,/[Ee]/,/[Dd]/,/[Nn]/,/[Ee]/,/[Ss]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ww]/,/[Ee]/,/[Dd]/],[/[Tt]/,/[Hh]/,/[Uu]/,/[Rr]/,/[Ss]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Tt]/,/[Hh]/,/[Uu]/],[/[Tt]/,/[Hh]/,/[Uu]/,/[Rr]/],[/[Ff]/,/[Rr]/,/[Ii]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ff]/,/[Rr]/,/[Ii]/],[/[Ss]/,/[Aa]/,/[Tt]/,/[Uu]/,/[Rr]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ss]/,/[Aa]/,/[Tt]/],[/[Ss]/,/[Uu]/,/[Nn]/,/[Dd]/,/[Aa]/,/[Yy]/],[/[Ss]/,/[Uu]/,/[Nn]/]]},{"name":"%dow_range[4]","bnf":[["WS+",/[Tt]/,/[Oo]/,"WS+","dow"]],"fragment":true},{"name":"dow_range","bnf":[[/[Oo]/,/[Nn]/,"WS+","dow","%dow_range[4]?"]]},{"name":"between_time_only","bnf":[[/[Bb]/,/[Ee]/,/[Tt]/,/[Ww]/,/[Ee]/,/[Ee]/,/[Nn]/,"WS+","between_number_time"]]},{"name":"between_tod_only","bnf":[[/[Bb]/,/[Ee]/,/[Tt]/,/[Ww]/,/[Ee]/,/[Ee]/,/[Nn]/,"WS+","between_tod"]]},{"name":"%AND[1]","bnf":[["WS*",/&/,/&/,"WS*"]],"fragment":true},{"name":"%AND[2]","bnf":[["WS+",/[Aa]/,/[Nn]/,/[Dd]/,"WS+"]],"fragment":true},{"name":"AND","bnf":[["%AND[1]"],["%AND[2]"]]},{"name":"%OR[1]","bnf":[["WS*",/\|/,/\|/,"WS*"]],"fragment":true},{"name":"%OR[2]","bnf":[["WS+",/[Oo]/,/[Rr]/,"WS+"]],"fragment":true},{"name":"OR","bnf":[["%OR[1]"],["%OR[2]"]]},{"name":"AGO","bnf":[[/[Aa]/,/[Gg]/,/[Oo]/]]},{"name":"GT","bnf":[["\">\""]]},{"name":"LT","bnf":[["\"<\""]]},{"name":"GTE","bnf":[["\">=\""]]},{"name":"LTE","bnf":[["\"<=\""]]},{"name":"IS","bnf":[[/[Ii]/,/[Ss]/]]},{"name":"EQ","bnf":[["\"==\""],["\"=\""]]},{"name":"NEQ","bnf":[["\"!=\""]]},{"name":"%NOT[1]","bnf":[[/!/,"WS*"]],"fragment":true},{"name":"%NOT[2]","bnf":[[/[Nn]/,/[Oo]/,/[Tt]/,"WS+"]],"fragment":true},{"name":"NOT","bnf":[["%NOT[1]"],["%NOT[2]"]]},{"name":"false","bnf":[[/[Ff]/,/[Aa]/,/[Ll]/,/[Ss]/,/[Ee]/]]},{"name":"null","bnf":[[/[Nn]/,/[Uu]/,/[Ll]/,/[Ll]/]]},{"name":"true","bnf":[[/[Tt]/,/[Rr]/,/[Uu]/,/[Ee]/]]},{"name":"%array[2][2]","bnf":[["VALUE_SEPARATOR","value"]],"fragment":true},{"name":"%array[2]","bnf":[["value","%array[2][2]*"]],"fragment":true},{"name":"array","bnf":[["BEGIN_ARRAY","%array[2]?","END_ARRAY"]]},{"name":"unit","bnf":[[/[Ss]/,/[Ee]/,/[Cc]/,/[Oo]/,/[Nn]/,/[Dd]/,/[Ss]/],[/[Mm]/,/[Ii]/,/[Nn]/,/[Uu]/,/[Tt]/,/[Ee]/,/[Ss]/],[/[Hh]/,/[Oo]/,/[Uu]/,/[Rr]/,/[Ss]/],[/[Ww]/,/[Ee]/,/[Ee]/,/[Kk]/,/[Ss]/],[/[Dd]/,/[Aa]/,/[Yy]/,/[Ss]/],[/[Ss]/,/[Ee]/,/[Cc]/,/[Oo]/,/[Nn]/,/[Dd]/],[/[Mm]/,/[Ii]/,/[Nn]/,/[Uu]/,/[Tt]/,/[Ee]/],[/[Ww]/,/[Ee]/,/[Ee]/,/[Kk]/],[/[Hh]/,/[Oo]/,/[Uu]/,/[Rr]/],[/[Dd]/,/[Aa]/,/[Yy]/],[/[Mm]/,/[Ii]/,/[Nn]/,/[Ss]/],[/[Mm]/,/[Ii]/,/[Nn]/]]},{"name":"%number[2][1]","bnf":[[/[0-9]/]]},{"name":"%number[2]","bnf":[["%number[2][1]+"]],"fragment":true},{"name":"%number[3][2]","bnf":[[/[0-9]/]]},{"name":"%number[3]","bnf":[["\".\"","%number[3][2]+"]],"fragment":true},{"name":"%number[4][2]","bnf":[["\"-\""],["\"+\""]],"fragment":true},{"name":"%number[4][3][2]","bnf":[[/[0-9]/]]},{"name":"%number[4][3]","bnf":[["\"0\""],[/[1-9]/,"%number[4][3][2]*"]],"fragment":true},{"name":"%number[4]","bnf":[["\"e\"","%number[4][2]?","%number[4][3]"]],"fragment":true},{"name":"number","bnf":[["\"-\"?","%number[2]","%number[3]?","%number[4]?"]]},{"name":"number_time","bnf":[["number","WS+","unit"]]},{"name":"%number_tod[1][1]","bnf":[[/[0-9]/]]},{"name":"%number_tod[1]","bnf":[["%number_tod[1][1]+"]],"fragment":true},{"name":"%number_tod[3][1]","bnf":[[/[0-9]/]]},{"name":"%number_tod[3]","bnf":[["%number_tod[3][1]+"]],"fragment":true},{"name":"number_tod","bnf":[["%number_tod[1]","\":\"","%number_tod[3]"]]},{"name":"%time_period_ago[2]","bnf":[["WS+","number_time"]],"fragment":true},{"name":"time_period_ago","bnf":[["number_time","%time_period_ago[2]*","WS+","AGO"]]},{"name":"%time_period_ago_between[2]","bnf":[["WS+","number_time"]],"fragment":true},{"name":"time_period_ago_between","bnf":[["number_time","%time_period_ago_between[2]*","WS+","AGO","WS+","between_tod_only"]]},{"name":"time_period_const","bnf":[[/[Tt]/,/[Oo]/,/[Dd]/,/[Aa]/,/[Yy]/],["time_period_ago"]]},{"name":"time_period","bnf":[["time_period_ago_between"],["time_period_const"],["between_tod_only"],["between_time_only"]]},{"name":"%string[2][1]","bnf":[[/[\x20-\x21]/],[/[\x23-\x5B]/],[/[\x5D-\uFFFF]/]],"fragment":true},{"name":"%string[2][2]","bnf":[[/\x22/],[/\x5C/],[/\x2F/],[/\x62/],[/\x66/],[/\x6E/],[/\x72/],[/\x74/],[/\x75/,"HEXDIG","HEXDIG","HEXDIG","HEXDIG"]],"fragment":true},{"name":"%string[2]","bnf":[["%string[2][1]"],[/\x5C/,"%string[2][2]"]],"fragment":true},{"name":"string","bnf":[["'\"'","%string[2]*","'\"'"]]},{"name":"HEXDIG","bnf":[[/[a-fA-F0-9]/]]}]
@@ -1,9 +1,12 @@
1
1
  const {Parser} = require('ebnf/dist/Parser.js'),
2
+ {ParsingError} = require('ebnf'),
2
3
  assert = require('assert')
3
4
 
4
5
  let ParserRules = require('./RuleParser.production.ebnf.js')
5
6
  let ParserCache;
6
7
 
8
+ const { ErrorAnalyzer } = require('./errors/ErrorAnalyzer');
9
+
7
10
  const ArithmeticOperators = {
8
11
  "+": 'MathAdd',
9
12
  "-": 'MathSub',
@@ -68,11 +71,22 @@ class RuleParser {
68
71
  ParserCache = new Parser(ParserRules, {debug: false})
69
72
  }
70
73
 
71
- ret = ParserCache.getAST(txt.trim(), 'statement_main');
74
+ try {
75
+ ret = ParserCache.getAST(txt.trim(), 'statement_main');
76
+ } catch (e) {
77
+ // If ebnf throws ParsingError, convert it to RuleParseError with helpful error code
78
+ if (e instanceof ParsingError) {
79
+ throw ErrorAnalyzer.analyzeParseFailure(txt, e);
80
+ }
81
+ throw e;
82
+ }
72
83
 
73
84
  if(ret){
74
85
  return ret.children[0]
75
86
  }
87
+
88
+ // If parsing failed without throwing (shouldn't happen with new ebnf), throw error
89
+ throw ErrorAnalyzer.analyzeParseFailure(txt);
76
90
  }
77
91
  static _parseArgument(argument){
78
92
  assert(argument.type === 'argument')
@@ -498,9 +512,72 @@ class RuleParser {
498
512
  }
499
513
  }
500
514
  static toIL(txt){
501
- const ast = RuleParser.toAst(txt)
502
- if(!ast) throw new Error(`failed to parse ${txt}`)
503
- return RuleParser._buildExpressionGroup(ast)
515
+ try {
516
+ const ast = RuleParser.toAst(txt)
517
+ if(!ast) throw new Error(`failed to parse ${txt}`)
518
+ return RuleParser._buildExpressionGroup(ast)
519
+ } catch (e) {
520
+ // If it's already a RuleParseError, just re-throw it
521
+ if (e.name === 'RuleParseError') {
522
+ throw e;
523
+ }
524
+
525
+ // Check if it's a validation error we can map to a specific code
526
+ if (e.message && e.message.includes('Invalid time of day')) {
527
+ // Extract the invalid time from the error message
528
+ const match = e.message.match(/Invalid time of day[,:]?\s*([0-9:]+)/);
529
+ const badTod = match ? match[1] : 'invalid';
530
+ const { ParsingError } = require('ebnf');
531
+ const { RuleParseError } = require('./errors/RuleParseError');
532
+
533
+ // Calculate position (simplified - at end of input)
534
+ const lines = txt.trim().split('\n');
535
+ const position = {
536
+ line: lines.length,
537
+ column: lines[lines.length - 1].length + 1,
538
+ offset: txt.trim().length
539
+ };
540
+
541
+ throw new RuleParseError(
542
+ "BAD_TOD",
543
+ `Invalid time of day: ${badTod}`,
544
+ "Time of day must be in HH:MM format with hours 0-23 and minutes 0-59, e.g. 08:30, 14:00, 23:59.",
545
+ position,
546
+ badTod,
547
+ ["HH:MM"],
548
+ txt.trim().substring(Math.max(0, txt.trim().length - 50))
549
+ );
550
+ }
551
+
552
+ // Check if it's a day of week error
553
+ if (e.message && e.message.includes('Invalid day of week')) {
554
+ const match = e.message.match(/Invalid day of week[,:]?\s*(\w+)/);
555
+ const badDow = match ? match[1] : 'invalid';
556
+ const { RuleParseError } = require('./errors/RuleParseError');
557
+
558
+ const lines = txt.trim().split('\n');
559
+ const position = {
560
+ line: lines.length,
561
+ column: lines[lines.length - 1].length + 1,
562
+ offset: txt.trim().length
563
+ };
564
+
565
+ throw new RuleParseError(
566
+ "BAD_DOW",
567
+ `Invalid day of week: ${badDow}`,
568
+ "Valid days are: MONDAY/MON, TUESDAY/TUE, WEDNESDAY/WED, THURSDAY/THU, FRIDAY/FRI, SATURDAY/SAT, SUNDAY/SUN.",
569
+ position,
570
+ badDow,
571
+ ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"],
572
+ txt.trim().substring(Math.max(0, txt.trim().length - 50))
573
+ );
574
+ }
575
+
576
+ // For other errors, re-throw
577
+ throw e;
578
+ }
504
579
  }
505
580
  }
506
581
  module.exports = RuleParser
582
+ module.exports.ParsingError = require('ebnf').ParsingError
583
+ module.exports.RuleParseError = require('./errors/RuleParseError').RuleParseError
@@ -0,0 +1,924 @@
1
+ const { ParsingError } = require('ebnf');
2
+ const { RuleParseError } = require('./RuleParseError');
3
+
4
+ /**
5
+ * Analyzes parsing errors and maps them to user-friendly error codes
6
+ */
7
+ class ErrorAnalyzer {
8
+ /**
9
+ * Analyze a ParsingError and return a user-friendly RuleParseError
10
+ * @param {string} input - The input string that failed to parse
11
+ * @param {ParsingError} parsingError - The ParsingError from the parser (optional)
12
+ * @returns {RuleParseError} - A user-friendly error with error code
13
+ */
14
+ static analyzeParseFailure(input, parsingError = null) {
15
+ const trimmedInput = input.trim();
16
+
17
+ // Check for empty or missing expression first (fast path)
18
+ if (!trimmedInput) {
19
+ const position = { line: 1, column: 1, offset: 0 };
20
+ return new RuleParseError(
21
+ "MISSING_EXPRESSION",
22
+ "Empty or missing expression.",
23
+ "Provide a valid rule expression.",
24
+ position,
25
+ "empty input",
26
+ ["expression"],
27
+ ""
28
+ );
29
+ }
30
+
31
+ // If we have a ParsingError, use its information
32
+ if (parsingError && parsingError instanceof ParsingError) {
33
+ return this._analyzeFromParsingError(input, parsingError);
34
+ }
35
+
36
+ // Fallback to string-based analysis (for validation errors)
37
+ const lines = trimmedInput.split('\n');
38
+ const line = lines.length;
39
+ const lastLine = lines[lines.length - 1] || '';
40
+ const column = lastLine.length + 1;
41
+ const offset = trimmedInput.length;
42
+
43
+ const position = { line, column, offset };
44
+
45
+ // Get snippet (last 50 chars or the whole input if shorter)
46
+ const snippetStart = Math.max(0, trimmedInput.length - 50);
47
+ const snippet = (snippetStart > 0 ? '...' : '') + trimmedInput.substring(snippetStart);
48
+
49
+ // Analyze the error pattern
50
+ const errorInfo = this._detectErrorPattern(trimmedInput, position, snippet);
51
+
52
+ return new RuleParseError(
53
+ errorInfo.code,
54
+ errorInfo.message,
55
+ errorInfo.hint,
56
+ position,
57
+ errorInfo.found,
58
+ errorInfo.expected,
59
+ snippet
60
+ );
61
+ }
62
+
63
+ /**
64
+ * Analyze error using ParsingError exception data
65
+ * @private
66
+ */
67
+ static _analyzeFromParsingError(input, err) {
68
+ const position = err.position;
69
+ const expected = err.expected || [];
70
+ const found = err.found || '';
71
+ const failureTree = err.failureTree || [];
72
+
73
+ // Get snippet around error position
74
+ const snippetStart = Math.max(0, position.offset - 20);
75
+ const snippetEnd = Math.min(input.length, position.offset + 30);
76
+ const snippet = (snippetStart > 0 ? '...' : '') +
77
+ input.substring(snippetStart, snippetEnd) +
78
+ (snippetEnd < input.length ? '...' : '');
79
+
80
+ // Analyze what was expected to determine error type using failureTree
81
+ const errorInfo = this._detectErrorFromFailureTree(input, position, expected, found, failureTree);
82
+
83
+ return new RuleParseError(
84
+ errorInfo.code,
85
+ errorInfo.message,
86
+ errorInfo.hint,
87
+ position,
88
+ found,
89
+ expected,
90
+ snippet
91
+ );
92
+ }
93
+
94
+ /**
95
+ * Detect error type from failureTree
96
+ * @private
97
+ */
98
+ static _detectErrorFromFailureTree(input, position, expected, found, failureTree) {
99
+ const beforeError = input.substring(0, position.offset);
100
+
101
+ // Helper to check if a name exists in the failure tree
102
+ const hasFailure = (name) => {
103
+ const search = (nodes) => {
104
+ if (!Array.isArray(nodes)) return false;
105
+ for (const node of nodes) {
106
+ if (node.name === name) return true;
107
+ if (node.children && search(node.children)) return true;
108
+ }
109
+ return false;
110
+ };
111
+ return search(failureTree);
112
+ };
113
+
114
+ // Helper to check if a name exists at top level only
115
+ const hasTopLevelFailure = (name) => {
116
+ if (!Array.isArray(failureTree)) return false;
117
+ return failureTree.some(node => node.name === name);
118
+ };
119
+
120
+ // PRIORITY 1: Check for unterminated string FIRST (before any balance checks)
121
+ // This must be checked early because unclosed strings can confuse other detections
122
+ if (this._hasUnterminatedString(input)) {
123
+ return {
124
+ code: "UNTERMINATED_STRING",
125
+ message: "Unterminated string literal.",
126
+ hint: "String literals must be enclosed in double quotes, e.g. \"hello world\".",
127
+ };
128
+ }
129
+
130
+ // PRIORITY 2: Check for bad number format
131
+ const badNum = this._findBadNumber(input);
132
+ if (badNum) {
133
+ return {
134
+ code: "BAD_NUMBER",
135
+ message: `Invalid number format: ${badNum}`,
136
+ hint: "Numbers must be valid integers or decimals, e.g. 42, 3.14, -5, 1.5e10.",
137
+ };
138
+ }
139
+
140
+ // PRIORITY 3: Check for token-level issues (extra characters after valid tokens)
141
+ // This must be checked before END_ARGUMENT/END_ARRAY to catch cases like WEDNESDAYY
142
+ const tokenError = this._detectExtraCharactersAfterToken(input, position, beforeError, found);
143
+ if (tokenError) {
144
+ return tokenError;
145
+ }
146
+
147
+ // PRIORITY 4: Check for specific parser contexts (function calls, arrays)
148
+ // But first check if we have bracket/paren imbalance that's more specific
149
+
150
+ // Check bracket balance first for extra brackets
151
+ const bracketBalance = this._checkBracketBalance(input);
152
+ if (bracketBalance < 0) {
153
+ // Extra closing bracket - this is more specific than function call context
154
+ return {
155
+ code: "UNMATCHED_BRACKET",
156
+ message: "Extra closing bracket detected.",
157
+ hint: "Every closing ']' must have a matching opening '['. Remove the extra closing bracket.",
158
+ };
159
+ }
160
+
161
+ // Check paren balance for certain cases
162
+ const parenBalance = this._checkParenBalance(input);
163
+ if (parenBalance < 0) {
164
+ // Extra closing paren - this is more specific than function call context
165
+ return {
166
+ code: "UNMATCHED_PAREN",
167
+ message: "Extra closing parenthesis detected.",
168
+ hint: "Every closing ')' must have a matching opening '('. Remove the extra closing parenthesis.",
169
+ };
170
+ }
171
+
172
+ // Check if parser was expecting END_ARGUMENT (closing paren for function)
173
+ // This indicates we're inside a function call context
174
+ if (hasFailure('END_ARGUMENT')) {
175
+ return {
176
+ code: "BAD_FUNCTION_CALL",
177
+ message: "Invalid function call syntax.",
178
+ hint: "Function calls must have matching parentheses, e.g. func() or func(arg1, arg2).",
179
+ };
180
+ }
181
+
182
+ // Check if parser was expecting END_ARRAY (closing bracket)
183
+ // This indicates we're inside an array context
184
+ if (hasFailure('END_ARRAY')) {
185
+ return {
186
+ code: "BAD_ARRAY_SYNTAX",
187
+ message: "Invalid array syntax.",
188
+ hint: "Arrays must be enclosed in brackets with comma-separated values, e.g. [1, 2, 3].",
189
+ };
190
+ }
191
+
192
+ // PRIORITY 5: Check for dangling operators
193
+
194
+ // Check for dangling logical operator (check input pattern directly)
195
+ if (found === "end of input") {
196
+ const trimmed = input.trim();
197
+ if (/&&\s*$/.test(trimmed)) {
198
+ return {
199
+ code: "DANGLING_LOGICAL_OPERATOR",
200
+ message: "Logical operator '&&' at end of expression.",
201
+ hint: "Logical operators (&&, ||, AND, OR) must be followed by an expression.",
202
+ };
203
+ }
204
+ if (/\|\|\s*$/.test(trimmed)) {
205
+ return {
206
+ code: "DANGLING_LOGICAL_OPERATOR",
207
+ message: "Logical operator '||' at end of expression.",
208
+ hint: "Logical operators (&&, ||, AND, OR) must be followed by an expression.",
209
+ };
210
+ }
211
+ if (/\bAND\b\s*$/i.test(trimmed)) {
212
+ return {
213
+ code: "DANGLING_LOGICAL_OPERATOR",
214
+ message: "Logical operator 'AND' at end of expression.",
215
+ hint: "Logical operators (&&, ||, AND, OR) must be followed by an expression.",
216
+ };
217
+ }
218
+ if (/\bOR\b\s*$/i.test(trimmed)) {
219
+ return {
220
+ code: "DANGLING_LOGICAL_OPERATOR",
221
+ message: "Logical operator 'OR' at end of expression.",
222
+ hint: "Logical operators (&&, ||, AND, OR) must be followed by an expression.",
223
+ };
224
+ }
225
+ }
226
+
227
+ // Check for BETWEEN - if we see BETWEEN in input with incomplete syntax
228
+ if (/\bBETWEEN\b/i.test(input)) {
229
+ // Check if BETWEEN is incomplete or missing AND
230
+ if (found === "end of input" || /\bBETWEEN\s+\d+\s*$/i.test(beforeError.trim())) {
231
+ return {
232
+ code: "BAD_BETWEEN_SYNTAX",
233
+ message: "Invalid BETWEEN syntax.",
234
+ hint: "BETWEEN requires two values: 'expr BETWEEN value1 AND value2' or 'expr BETWEEN value1-value2'.",
235
+ };
236
+ }
237
+ }
238
+
239
+ // Check if parser expected BEGIN_ARGUMENT - could be missing RHS after operator
240
+ if (hasTopLevelFailure('BEGIN_ARGUMENT')) {
241
+ const trimmed = beforeError.trim();
242
+ // If found is a comparison operator, this is likely missing RHS
243
+ if (found.match(/^[><=!]/)) {
244
+ return {
245
+ code: "MISSING_RHS_AFTER_OPERATOR",
246
+ message: `Expected a value after '${found}'.`,
247
+ hint: "Comparison operators must be followed by a value, e.g. temp > 25, name == \"bob\".",
248
+ };
249
+ }
250
+
251
+ // Also check if beforeError ends with comparison operator
252
+ const compOps = ['>=', '<=', '==', '!=', '>', '<', '=', '==~', '!=~'];
253
+ for (const op of compOps) {
254
+ if (trimmed.endsWith(op)) {
255
+ return {
256
+ code: "MISSING_RHS_AFTER_OPERATOR",
257
+ message: `Expected a value after '${op}'.`,
258
+ hint: "Comparison operators must be followed by a value, e.g. temp > 25, name == \"bob\".",
259
+ };
260
+ }
261
+ }
262
+ }
263
+
264
+ // Check for value/result expected (could be missing RHS)
265
+ if ((hasFailure('value') || hasFailure('result') || hasFailure('simple_result')) && found === "end of input") {
266
+ // Check for comparison operator
267
+ const trimmed = beforeError.trim();
268
+ const compOps = ['>=', '<=', '==', '!=', '>', '<', '=', '==~', '!=~'];
269
+
270
+ for (const op of compOps) {
271
+ if (trimmed.endsWith(op)) {
272
+ return {
273
+ code: "MISSING_RHS_AFTER_OPERATOR",
274
+ message: `Expected a value after '${op}'.`,
275
+ hint: "Comparison operators must be followed by a value, e.g. temp > 25, name == \"bob\".",
276
+ };
277
+ }
278
+ }
279
+ }
280
+
281
+ // PRIORITY 6: Final fallback - general balance checks for unclosed parens/brackets
282
+ // (Note: extra closing parens/brackets were already checked in PRIORITY 4)
283
+
284
+ // Check for unclosed parentheses (only if not already handled)
285
+ if (parenBalance > 0 && found === "end of input") {
286
+ // Check if this looks like a function call context
287
+ const looksLikeFunction = /[a-zA-Z_][a-zA-Z0-9_]*\s*\([^)]*$/.test(input);
288
+ const message = looksLikeFunction
289
+ ? "Unclosed parenthesis in function call."
290
+ : "Unclosed parenthesis detected.";
291
+
292
+ return {
293
+ code: "UNMATCHED_PAREN",
294
+ message: message,
295
+ hint: "Every opening '(' must have a matching closing ')'. Check your expression for missing closing parentheses.",
296
+ };
297
+ }
298
+
299
+ // Check for unclosed brackets (only if not already handled)
300
+ if (bracketBalance > 0 && found === "end of input") {
301
+ // Check if this looks like an array context
302
+ const looksLikeArray = /\[[^\]]*$/.test(input);
303
+ const message = looksLikeArray
304
+ ? "Unclosed bracket in array."
305
+ : "Unclosed bracket detected.";
306
+
307
+ return {
308
+ code: "UNMATCHED_BRACKET",
309
+ message: message,
310
+ hint: "Every opening '[' must have a matching closing ']'. Check your array syntax.",
311
+ };
312
+ }
313
+
314
+ // Default to unexpected token
315
+ const foundDesc = found === "end of input" ? "end of input" : `'${found}'`;
316
+ return {
317
+ code: "UNEXPECTED_TOKEN",
318
+ message: `Unexpected ${foundDesc} at position ${position.offset}.`,
319
+ hint: "Check your syntax. Common issues include missing operators, invalid characters, or malformed expressions.",
320
+ };
321
+ }
322
+
323
+ /**
324
+ * Detect extra characters after a valid token (e.g., WEDNESDAYY has extra Y after WEDNESDAY)
325
+ * @private
326
+ */
327
+ static _detectExtraCharactersAfterToken(input, position, beforeError, found) {
328
+ // First, check for invalid day of week patterns
329
+ // Pattern: "ON" followed by partial day name
330
+ const onMatch = /\bON\s+(\w+)$/i.exec(beforeError);
331
+ if (onMatch) {
332
+ const partial = onMatch[1].toUpperCase();
333
+ // Check if this looks like a partial day name or invalid day
334
+ // Note: THUR is not included because it's not actually supported by the parser
335
+ const validDays = ['MONDAY', 'MON', 'TUESDAY', 'TUE', 'WEDNESDAY', 'WED',
336
+ 'THURSDAY', 'THU', 'FRIDAY', 'FRI', 'SATURDAY',
337
+ 'SAT', 'SUNDAY', 'SUN'];
338
+
339
+ if (!validDays.includes(partial)) {
340
+ // This might be part of an invalid day name
341
+ // Combine with found to get the full attempted day name if found is alphabetic
342
+ const fullAttempt = partial + (found && found !== "end of input" && found.match(/^[A-Z]/i) ? found : "");
343
+
344
+ // Check if it looks like a misspelled day
345
+ const possibleMisspelling = validDays.some(day => {
346
+ // Check for similarity (starts with same letter, length close)
347
+ return day.startsWith(partial.charAt(0)) &&
348
+ Math.abs(day.length - fullAttempt.length) <= 3;
349
+ });
350
+
351
+ if (possibleMisspelling || fullAttempt.length >= 3) {
352
+ return {
353
+ code: "BAD_DOW",
354
+ message: `Invalid day of week: ${fullAttempt}`,
355
+ hint: "Valid days are: MONDAY/MON, TUESDAY/TUE, WEDNESDAY/WED, THURSDAY/THU, FRIDAY/FRI, SATURDAY/SAT, SUNDAY/SUN.",
356
+ };
357
+ }
358
+ }
359
+ }
360
+
361
+ // Check for time of day patterns right before error
362
+ const todMatch = /(\d{1,2}):(-?\d{1,2})?$/.exec(beforeError);
363
+ if (todMatch || (beforeError.match(/\d+:$/) && found && found.match(/^-?\d+/))) {
364
+ // Looks like we're in a time of day context
365
+ // Extract the full time attempt
366
+ let fullTime = todMatch ? todMatch[0] : '';
367
+ if (!todMatch && beforeError.match(/\d+:$/)) {
368
+ // We have a number: before and digits after
369
+ const num = beforeError.match(/\d+:$/)[0];
370
+ const minutes = found.match(/^-?\d+/)[0];
371
+ fullTime = num + minutes;
372
+ }
373
+
374
+ // Check if it's a bad time
375
+ const timePattern = /(\d{1,2}):(-?\d{1,2})/;
376
+ const timeMatch = timePattern.exec(fullTime);
377
+ if (timeMatch) {
378
+ const hours = parseInt(timeMatch[1], 10);
379
+ const minutes = parseInt(timeMatch[2], 10);
380
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
381
+ return {
382
+ code: "BAD_TOD",
383
+ message: `Invalid time of day: ${timeMatch[0]}`,
384
+ hint: "Time of day must be in HH:MM format with hours 0-23 and minutes 0-59, e.g. 08:30, 14:00, 23:59.",
385
+ };
386
+ }
387
+ }
388
+ }
389
+
390
+ // List of known valid tokens (keywords, days of week, etc.)
391
+ const validTokens = [
392
+ // Days of week
393
+ 'MONDAY', 'MON', 'TUESDAY', 'TUE', 'WEDNESDAY', 'WED',
394
+ 'THURSDAY', 'THU', 'THUR', 'FRIDAY', 'FRI', 'SATURDAY',
395
+ 'SAT', 'SUNDAY', 'SUN',
396
+ // Keywords
397
+ 'BETWEEN', 'AND', 'OR', 'NOT', 'IN', 'ON'
398
+ ];
399
+
400
+ // Check if any valid token appears right before the error position
401
+ for (const token of validTokens) {
402
+ if (beforeError.toUpperCase().endsWith(token)) {
403
+ // Found a valid token right before error - check if we have extra characters
404
+ if (found && found.length > 0 && found !== "end of input" && found.match(/^[A-Z]/i)) {
405
+ // We have alphabetic characters after a valid token
406
+ const extraChars = found.charAt(0);
407
+ return {
408
+ code: "UNEXPECTED_TOKEN",
409
+ message: `Unexpected characters after '${token}'. Remove the extra '${extraChars}'.`,
410
+ hint: `'${token}' is a valid keyword. Remove any extra characters that follow it.`,
411
+ };
412
+ }
413
+ }
414
+ }
415
+
416
+ return null;
417
+ }
418
+
419
+ /**
420
+ * Detect the error pattern in the input (fallback for non-ParsingError cases)
421
+ * @private
422
+ * @param {string} input - The input to analyze
423
+ * @param {Object} position - Position information
424
+ * @param {string} snippet - Code snippet
425
+ * @returns {Object} Error information
426
+ */
427
+ static _detectErrorPattern(input, position, snippet) {
428
+ // Check for unterminated string first (this affects other checks)
429
+ if (this._hasUnterminatedString(input)) {
430
+ return {
431
+ code: "UNTERMINATED_STRING",
432
+ message: "Unterminated string literal.",
433
+ hint: "String literals must be enclosed in double quotes, e.g. \"hello world\".",
434
+ found: "end of input",
435
+ expected: ["\""]
436
+ };
437
+ }
438
+
439
+ // Check for dangling logical operators
440
+ const danglingOp = this._findDanglingLogicalOperator(input);
441
+ if (danglingOp) {
442
+ return {
443
+ code: "DANGLING_LOGICAL_OPERATOR",
444
+ message: `Logical operator '${danglingOp}' at end of expression.`,
445
+ hint: "Logical operators (&&, ||, AND, OR) must be followed by an expression.",
446
+ found: danglingOp,
447
+ expected: ["expression", "function call", "value"]
448
+ };
449
+ }
450
+
451
+ // Check for missing RHS after comparison operator
452
+ const danglingCompOp = this._findDanglingComparisonOperator(input);
453
+ if (danglingCompOp) {
454
+ return {
455
+ code: "MISSING_RHS_AFTER_OPERATOR",
456
+ message: `Expected a value after '${danglingCompOp}'.`,
457
+ hint: "Comparison operators must be followed by a value, e.g. temp > 25, name == \"bob\".",
458
+ found: "end of input",
459
+ expected: ["value", "function call", "number", "string"]
460
+ };
461
+ }
462
+
463
+ // Check for bad BETWEEN syntax
464
+ if (this._hasBadBetweenSyntax(input)) {
465
+ return {
466
+ code: "BAD_BETWEEN_SYNTAX",
467
+ message: "Invalid BETWEEN syntax.",
468
+ hint: "BETWEEN requires two values: 'expr BETWEEN value1 AND value2' or 'expr BETWEEN value1-value2'.",
469
+ found: "incomplete BETWEEN expression",
470
+ expected: ["BETWEEN value1 AND value2"]
471
+ };
472
+ }
473
+
474
+ // Check for bad time of day (TOD) format
475
+ const badTod = this._findBadTimeOfDay(input);
476
+ if (badTod) {
477
+ return {
478
+ code: "BAD_TOD",
479
+ message: `Invalid time of day: ${badTod.value}`,
480
+ hint: "Time of day must be in HH:MM format with hours 0-23 and minutes 0-59, e.g. 08:30, 14:00, 23:59.",
481
+ found: badTod.value,
482
+ expected: ["HH:MM"]
483
+ };
484
+ }
485
+
486
+ // Check for bad day of week (DOW)
487
+ const badDow = this._findBadDayOfWeek(input);
488
+ if (badDow) {
489
+ return {
490
+ code: "BAD_DOW",
491
+ message: `Invalid day of week: ${badDow}`,
492
+ hint: "Valid days are: MONDAY/MON, TUESDAY/TUE, WEDNESDAY/WED, THURSDAY/THU, FRIDAY/FRI, SATURDAY/SAT, SUNDAY/SUN.",
493
+ found: badDow,
494
+ expected: ["MONDAY", "TUESDAY", "WEDNESDAY", "THURSDAY", "FRIDAY", "SATURDAY", "SUNDAY"]
495
+ };
496
+ }
497
+
498
+ // Check for bad number format
499
+ const badNumber = this._findBadNumber(input);
500
+ if (badNumber) {
501
+ return {
502
+ code: "BAD_NUMBER",
503
+ message: `Invalid number format: ${badNumber}`,
504
+ hint: "Numbers must be valid integers or decimals, e.g. 42, 3.14, -5, 1.5e10.",
505
+ found: badNumber,
506
+ expected: ["valid number"]
507
+ };
508
+ }
509
+
510
+ // Check for bad function call syntax (more specific than general paren check)
511
+ if (this._hasBadFunctionCall(input)) {
512
+ return {
513
+ code: "BAD_FUNCTION_CALL",
514
+ message: "Invalid function call syntax.",
515
+ hint: "Function calls must have matching parentheses, e.g. func() or func(arg1, arg2).",
516
+ found: "incomplete function call",
517
+ expected: [")"]
518
+ };
519
+ }
520
+
521
+ // Check for bad array syntax (more specific than general bracket check)
522
+ if (this._hasBadArraySyntax(input)) {
523
+ return {
524
+ code: "BAD_ARRAY_SYNTAX",
525
+ message: "Invalid array syntax.",
526
+ hint: "Arrays must be enclosed in brackets with comma-separated values, e.g. [1, 2, 3].",
527
+ found: "incomplete array",
528
+ expected: ["]", ","]
529
+ };
530
+ }
531
+
532
+ // Check for unmatched parentheses (general check)
533
+ const parenBalance = this._checkParenBalance(input);
534
+ if (parenBalance > 0) {
535
+ return {
536
+ code: "UNMATCHED_PAREN",
537
+ message: "Unclosed parenthesis detected.",
538
+ hint: "Every opening '(' must have a matching closing ')'. Check your expression for missing closing parentheses.",
539
+ found: "end of input",
540
+ expected: [")"]
541
+ };
542
+ } else if (parenBalance < 0) {
543
+ const closeIndex = this._findExtraCloseParen(input);
544
+ return {
545
+ code: "UNMATCHED_PAREN",
546
+ message: "Extra closing parenthesis detected.",
547
+ hint: "Every closing ')' must have a matching opening '('. Remove the extra closing parenthesis.",
548
+ found: ")",
549
+ expected: ["expression", "operator"]
550
+ };
551
+ }
552
+
553
+ // Check for unmatched brackets (general check)
554
+ const bracketBalance = this._checkBracketBalance(input);
555
+ if (bracketBalance > 0) {
556
+ return {
557
+ code: "UNMATCHED_BRACKET",
558
+ message: "Unclosed bracket detected.",
559
+ hint: "Every opening '[' must have a matching closing ']'. Check your array syntax.",
560
+ found: "end of input",
561
+ expected: ["]"]
562
+ };
563
+ } else if (bracketBalance < 0) {
564
+ return {
565
+ code: "UNMATCHED_BRACKET",
566
+ message: "Extra closing bracket detected.",
567
+ hint: "Every closing ']' must have a matching opening '['. Remove the extra closing bracket.",
568
+ found: "]",
569
+ expected: ["expression", "operator"]
570
+ };
571
+ }
572
+
573
+ // Check for multiple expressions without logical operators
574
+ if (this._hasMultipleExpressionsWithoutOperator(input)) {
575
+ return {
576
+ code: "UNEXPECTED_TOKEN",
577
+ message: "Multiple expressions without logical operator.",
578
+ hint: "Use logical operators (&&, ||, AND, OR) to combine multiple expressions.",
579
+ found: "expression",
580
+ expected: ["&&", "||", "AND", "OR"]
581
+ };
582
+ }
583
+
584
+ // Default to unexpected token
585
+ const found = this._getLastToken(input) || "unknown token";
586
+ return {
587
+ code: "UNEXPECTED_TOKEN",
588
+ message: `Unexpected token: ${found}`,
589
+ hint: "Check your syntax. Common issues include missing operators, invalid characters, or malformed expressions.",
590
+ found: found,
591
+ expected: ["valid expression"]
592
+ };
593
+ }
594
+
595
+ /**
596
+ * Check if string is properly terminated
597
+ * @private
598
+ * @param {string} input - Input to check
599
+ * @returns {boolean} True if string is unterminated
600
+ */
601
+ static _hasUnterminatedString(input) {
602
+ let inString = false;
603
+ let i = 0;
604
+ while (i < input.length) {
605
+ const char = input[i];
606
+
607
+ if (char === '"') {
608
+ if (!inString) {
609
+ inString = true;
610
+ } else {
611
+ // Check if this quote is escaped by counting preceding backslashes
612
+ let backslashCount = 0;
613
+ let j = i - 1;
614
+ while (j >= 0 && input[j] === '\\') {
615
+ backslashCount++;
616
+ j--;
617
+ }
618
+ // If even number of backslashes (including 0), the quote is not escaped
619
+ if (backslashCount % 2 === 0) {
620
+ inString = false;
621
+ }
622
+ }
623
+ }
624
+ i++;
625
+ }
626
+ return inString;
627
+ }
628
+
629
+ /**
630
+ * Check parenthesis balance
631
+ * @private
632
+ * @param {string} input - Input to check
633
+ * @returns {number} Balance (positive = unclosed, negative = extra closing)
634
+ */
635
+ static _checkParenBalance(input) {
636
+ let balance = 0;
637
+ let inString = false;
638
+ let i = 0;
639
+ while (i < input.length) {
640
+ const char = input[i];
641
+
642
+ if (char === '"') {
643
+ if (!inString) {
644
+ inString = true;
645
+ } else {
646
+ // Check if this quote is escaped
647
+ let backslashCount = 0;
648
+ let j = i - 1;
649
+ while (j >= 0 && input[j] === '\\') {
650
+ backslashCount++;
651
+ j--;
652
+ }
653
+ if (backslashCount % 2 === 0) {
654
+ inString = false;
655
+ }
656
+ }
657
+ }
658
+
659
+ // Only count parentheses outside of strings
660
+ if (!inString) {
661
+ if (char === '(') balance++;
662
+ else if (char === ')') balance--;
663
+ }
664
+ i++;
665
+ }
666
+ return balance;
667
+ }
668
+
669
+ /**
670
+ * Find position of extra closing paren
671
+ * @private
672
+ */
673
+ static _findExtraCloseParen(input) {
674
+ let balance = 0;
675
+ let inString = false;
676
+ let i = 0;
677
+ while (i < input.length) {
678
+ const char = input[i];
679
+
680
+ if (char === '"') {
681
+ if (!inString) {
682
+ inString = true;
683
+ } else {
684
+ // Check if this quote is escaped
685
+ let backslashCount = 0;
686
+ let j = i - 1;
687
+ while (j >= 0 && input[j] === '\\') {
688
+ backslashCount++;
689
+ j--;
690
+ }
691
+ if (backslashCount % 2 === 0) {
692
+ inString = false;
693
+ }
694
+ }
695
+ }
696
+
697
+ if (!inString) {
698
+ if (char === '(') balance++;
699
+ else if (char === ')') {
700
+ balance--;
701
+ if (balance < 0) return i;
702
+ }
703
+ }
704
+ i++;
705
+ }
706
+ return -1;
707
+ }
708
+
709
+ /**
710
+ * Check bracket balance
711
+ * @private
712
+ * @param {string} input - Input to check
713
+ * @returns {number} Balance (positive = unclosed, negative = extra closing)
714
+ */
715
+ static _checkBracketBalance(input) {
716
+ let balance = 0;
717
+ let inString = false;
718
+ let i = 0;
719
+ while (i < input.length) {
720
+ const char = input[i];
721
+
722
+ if (char === '"') {
723
+ if (!inString) {
724
+ inString = true;
725
+ } else {
726
+ // Check if this quote is escaped
727
+ let backslashCount = 0;
728
+ let j = i - 1;
729
+ while (j >= 0 && input[j] === '\\') {
730
+ backslashCount++;
731
+ j--;
732
+ }
733
+ if (backslashCount % 2 === 0) {
734
+ inString = false;
735
+ }
736
+ }
737
+ }
738
+
739
+ if (!inString) {
740
+ if (char === '[') balance++;
741
+ else if (char === ']') balance--;
742
+ }
743
+ i++;
744
+ }
745
+ return balance;
746
+ }
747
+
748
+ static _findDanglingLogicalOperator(input) {
749
+ const trimmed = input.trim();
750
+ const logicalOps = [
751
+ { pattern: /&&\s*$/, op: '&&' },
752
+ { pattern: /\|\|\s*$/, op: '||' },
753
+ { pattern: /\bAND\b\s*$/i, op: 'AND' },
754
+ { pattern: /\bOR\b\s*$/i, op: 'OR' }
755
+ ];
756
+
757
+ for (const { pattern, op } of logicalOps) {
758
+ if (pattern.test(trimmed)) {
759
+ return op;
760
+ }
761
+ }
762
+ return null;
763
+ }
764
+
765
+ static _findDanglingComparisonOperator(input) {
766
+ const trimmed = input.trim();
767
+ const compOps = ['>=', '<=', '==', '!=', '>', '<', '=', '==~', '!=~'];
768
+
769
+ for (const op of compOps) {
770
+ const pattern = new RegExp(`${this._escapeRegex(op)}\\s*$`);
771
+ if (pattern.test(trimmed)) {
772
+ return op;
773
+ }
774
+ }
775
+ return null;
776
+ }
777
+
778
+ static _hasBadBetweenSyntax(input) {
779
+ // Check if BETWEEN keyword exists but not followed by proper syntax
780
+ if (/\bBETWEEN\b/i.test(input)) {
781
+ // Check for valid BETWEEN patterns
782
+ const hasValidBetweenAnd = /\bBETWEEN\s+\S+\s+AND\s+\S+/i.test(input);
783
+ const hasValidBetweenDash = /\bBETWEEN\s+\S+-\s*\S+/i.test(input);
784
+ return !hasValidBetweenAnd && !hasValidBetweenDash;
785
+ }
786
+ return false;
787
+ }
788
+
789
+ static _hasBadFunctionCall(input) {
790
+ // Only return true if:
791
+ // 1. We have a function pattern (identifier followed by open paren)
792
+ // 2. The parentheses are unbalanced
793
+ // 3. There are NO array brackets (so we don't confuse arrays with function calls)
794
+ // 4. The imbalance is specifically in a function call context
795
+
796
+ const funcPattern = /[a-zA-Z0-9]+\s*\(/;
797
+ const hasArrayBracket = /\[/.test(input);
798
+
799
+ if (!funcPattern.test(input) || hasArrayBracket) {
800
+ return false;
801
+ }
802
+
803
+ const parenBalance = this._checkParenBalance(input);
804
+ if (parenBalance > 0) {
805
+ // We have unclosed parens in what looks like a function call
806
+ // Make sure it's not just a general parenthesis expression
807
+ // by checking if the first occurrence of ( is preceded by an identifier
808
+ const firstParenMatch = input.match(/^[^(]*([a-zA-Z0-9]+)\s*\(/);
809
+ if (firstParenMatch) {
810
+ return true;
811
+ }
812
+ }
813
+
814
+ return false;
815
+ }
816
+
817
+ static _hasBadArraySyntax(input) {
818
+ // Only return true if:
819
+ // 1. We have an array bracket [
820
+ // 2. The brackets are unbalanced
821
+ // 3. It's clearly in an array context (inside function args or standalone)
822
+
823
+ if (!/\[/.test(input)) {
824
+ return false;
825
+ }
826
+
827
+ const bracketBalance = this._checkBracketBalance(input);
828
+ if (bracketBalance > 0) {
829
+ // We have unclosed brackets - this is likely an array issue
830
+ return true;
831
+ }
832
+
833
+ return false;
834
+ }
835
+
836
+ static _hasMultipleExpressionsWithoutOperator(input) {
837
+ // Detect patterns like "A() B()" without && or || between them
838
+ // Check if there's no logical operator between function calls
839
+ const betweenPattern = /\)\s+(?!&&|\|\||AND|OR|BETWEEN)/i;
840
+ if (betweenPattern.test(input)) {
841
+ // Make sure the next thing after ) is an identifier (suggesting another expression)
842
+ return /\)\s+[a-zA-Z]/.test(input);
843
+ }
844
+ return false;
845
+ }
846
+
847
+ static _getLastToken(input) {
848
+ const trimmed = input.trim();
849
+ // Get last 20 characters or the whole string if shorter
850
+ const lastPart = trimmed.substring(Math.max(0, trimmed.length - 20));
851
+ return lastPart;
852
+ }
853
+
854
+ /**
855
+ * Check for invalid time of day format (e.g., 25:00, 12:60)
856
+ * @private
857
+ */
858
+ static _findBadTimeOfDay(input) {
859
+ // Look for patterns like HH:MM where hours or minutes are out of range or negative
860
+ const todPattern = /\b(-?\d{1,2}):(-?\d{1,2})\b/g;
861
+ let match;
862
+
863
+ while ((match = todPattern.exec(input)) !== null) {
864
+ const hours = parseInt(match[1], 10);
865
+ const minutes = parseInt(match[2], 10);
866
+ const fullMatch = match[0];
867
+
868
+ // Check if hours or minutes are out of valid range or negative
869
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
870
+ return { value: fullMatch, hours, minutes };
871
+ }
872
+ }
873
+ return null;
874
+ }
875
+
876
+ /**
877
+ * Check for invalid day of week
878
+ * @private
879
+ */
880
+ static _findBadDayOfWeek(input) {
881
+ // Look for ON keyword followed by potential day name
882
+ const onPattern = /\bON\s+([A-Z]+)/i;
883
+ const match = onPattern.exec(input);
884
+
885
+ if (match) {
886
+ const dayCandidate = match[1].toUpperCase();
887
+ const validDays = ['MONDAY', 'MON', 'TUESDAY', 'TUE', 'WEDNESDAY', 'WED',
888
+ 'THURSDAY', 'THU', 'THUR', 'FRIDAY', 'FRI', 'SATURDAY',
889
+ 'SAT', 'SUNDAY', 'SUN'];
890
+
891
+ if (!validDays.includes(dayCandidate)) {
892
+ return dayCandidate;
893
+ }
894
+ }
895
+ return null;
896
+ }
897
+
898
+ /**
899
+ * Check for invalid number format (e.g., 1.2.3, 123abc)
900
+ * @private
901
+ */
902
+ static _findBadNumber(input) {
903
+ // Look for malformed numbers - multiple decimal points or numbers followed by letters
904
+ const badNumberPatterns = [
905
+ /\b\d+\.\d+\.\d+/, // Multiple decimal points like 1.2.3
906
+ /\b\d+[a-zA-Z]+\d*/, // Numbers with letters mixed in like 123abc
907
+ /\b\d+\.\d+[a-zA-Z]+/ // Decimals with letters like 1.5abc
908
+ ];
909
+
910
+ for (const pattern of badNumberPatterns) {
911
+ const match = pattern.exec(input);
912
+ if (match) {
913
+ return match[0];
914
+ }
915
+ }
916
+ return null;
917
+ }
918
+
919
+ static _escapeRegex(str) {
920
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
921
+ }
922
+ }
923
+
924
+ module.exports = { ErrorAnalyzer };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Represents a user-friendly rule parsing error with error codes
3
+ */
4
+ class RuleParseError extends Error {
5
+ /**
6
+ * Create a RuleParseError
7
+ * @param {string} code - Error code for programmatic handling
8
+ * @param {string} message - Human-readable error message
9
+ * @param {string} hint - Helpful suggestion for fixing the error
10
+ * @param {Object} position - Position information with line, column, and offset
11
+ * @param {string} found - What was found at error position
12
+ * @param {Array<string>} expected - What was expected
13
+ * @param {string} snippet - Code snippet showing error location
14
+ */
15
+ constructor(code, message, hint, position, found, expected, snippet) {
16
+ super(message);
17
+ this.name = 'RuleParseError';
18
+ this.code = code;
19
+ this.hint = hint;
20
+ this.line = position.line;
21
+ this.column = position.column;
22
+ this.offset = position.offset;
23
+ this.found = found;
24
+ this.expected = expected;
25
+ this.snippet = snippet;
26
+
27
+ // Maintain proper prototype chain for instanceof checks
28
+ Object.setPrototypeOf(this, RuleParseError.prototype);
29
+ }
30
+
31
+ toString() {
32
+ let msg = `${this.name} [${this.code}]: ${this.message}\n`;
33
+ msg += ` at line ${this.line}, column ${this.column} (offset ${this.offset})\n`;
34
+ if (this.snippet) {
35
+ msg += ` ${this.snippet}\n`;
36
+ }
37
+ if (this.hint) {
38
+ msg += ` Hint: ${this.hint}\n`;
39
+ }
40
+ if (this.found) {
41
+ msg += ` Found: ${this.found}\n`;
42
+ }
43
+ if (this.expected && this.expected.length) {
44
+ msg += ` Expected: ${this.expected.join(', ')}`;
45
+ }
46
+ return msg;
47
+ }
48
+
49
+ toJSON() {
50
+ return {
51
+ code: this.code,
52
+ message: this.message,
53
+ hint: this.hint,
54
+ line: this.line,
55
+ column: this.column,
56
+ offset: this.offset,
57
+ found: this.found,
58
+ expected: this.expected,
59
+ snippet: this.snippet
60
+ };
61
+ }
62
+ }
63
+
64
+ module.exports = { RuleParseError };