chemql-python 1.6.2__py3-none-any.whl → 1.6.4__py3-none-any.whl

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.
chemql/__init__.py CHANGED
@@ -68,7 +68,7 @@ __all__ = [
68
68
  "__version__"
69
69
  ]
70
70
 
71
- __version__ = "1.6.2"
71
+ __version__ = "1.6.4"
72
72
 
73
73
  # Persistent preprocessor/function state for the current ChemQL session.
74
74
  DEFINITIONS = {}
@@ -78,6 +78,7 @@ EXECUTION_HISTORY = []
78
78
  _DEFINITION_NAME = r"[A-Za-z_][A-Za-z0-9_]*"
79
79
  _NO_RETURN = object()
80
80
  NO_ANSI = True
81
+ SUPPRESS_WARNINGS = False
81
82
 
82
83
  def _expand_definitions(line):
83
84
  """Replace @NAME literally, including inside strings, using current definitions."""
@@ -870,7 +871,17 @@ def _call_function(name, arguments):
870
871
  signal_match = re.fullmatch(r"signal\s+(.+)", line, re.IGNORECASE)
871
872
  if signal_match:
872
873
  raise ChemQLSignal(evaluate_python(signal_match.group(1)))
874
+ warn_match = re.fullmatch(r"warn\s+(.+)", line, re.IGNORECASE)
875
+ if warn_match:
876
+ warning = evaluate_python(warn_match.group(1))
877
+ python_globals["warnings"] = warning
873
878
 
879
+ if not SUPPRESS_WARNINGS:
880
+ message = f"Warning: {warning}"
881
+ print(message if NO_ANSI else f"\033[33m{message}\033[0m")
882
+
883
+ i += 1
884
+ continue
874
885
  # Multiline Python block.
875
886
  if line == "[[[":
876
887
  block = []
@@ -1094,24 +1105,51 @@ def _expand_function_calls(text):
1094
1105
  if match:
1095
1106
  name = match.group(1)
1096
1107
  resolved_name = _resolve_package_qualified_name(name)
1108
+
1109
+ # Resolve unqualified function calls against the active namespace.
1110
+ # This allows namespace functions to call themselves and other
1111
+ # functions in the same namespace without repeating the prefix.
1112
+ if "::" not in resolved_name and _CURRENT_NAMESPACE is not None:
1113
+ local_name = f"{_CURRENT_NAMESPACE._chemql_name}::{resolved_name}"
1114
+
1115
+ if local_name in FUNCTIONS:
1116
+ resolved_name = local_name
1097
1117
  # Enforce explicit package loading BEFORE checking FUNCTIONS.
1098
1118
  # Otherwise a function registered while internally initializing
1099
1119
  # a package bypasses the qualified-access restriction below.
1100
1120
  if "::" in resolved_name:
1101
1121
  package_name = resolved_name.rsplit("::", 1)[0]
1102
1122
  root_package = package_name.split("::", 1)[0]
1123
+
1103
1124
  explicitly_loaded_packages = globals().get(
1104
1125
  "CHEMQL_EXPLICITLY_LOADED_PACKAGES", set()
1105
1126
  )
1127
+ loaded_package_sources = globals().get(
1128
+ "CHEMQL_PACKAGE_SOURCE_LOADED", set()
1129
+ )
1130
+
1131
+ is_package_namespace = (
1132
+ package_name in loaded_package_sources
1133
+ or root_package in loaded_package_sources
1134
+ )
1135
+
1136
+ inside_own_namespace = (
1137
+ _CURRENT_NAMESPACE is not None
1138
+ and _CURRENT_NAMESPACE._chemql_name == package_name
1139
+ )
1140
+
1106
1141
  if (
1107
- package_name not in explicitly_loaded_packages
1142
+ is_package_namespace
1143
+ and not inside_own_namespace
1144
+ and package_name not in explicitly_loaded_packages
1108
1145
  and root_package not in explicitly_loaded_packages
1109
1146
  ):
1110
1147
  raise ChemQLError(
1111
1148
  f"Unknown function `{name}`"
1112
1149
  if NO_ANSI else
1113
- f"Unknown function \\033[1m{name}\\033[22m"
1150
+ f"Unknown function \033[1m{name}\033[22m"
1114
1151
  )
1152
+
1115
1153
  opening = i + match.end() - 1
1116
1154
  end = find_matching(text, opening, "{", "}")
1117
1155
  args = _split_function_arguments(text[opening + 1:end])
@@ -1235,6 +1273,7 @@ while ($true) {
1235
1273
  $commandText = [System.Text.Encoding]::UTF8.GetString(
1236
1274
  [System.Convert]::FromBase64String($encodedCommand)
1237
1275
  )
1276
+ [Console]::Out.Flush()
1238
1277
  if ([string]::IsNullOrWhiteSpace($commandText)) {
1239
1278
  [Console]::Out.WriteLine('ChemQL received an empty PowerShell command.')
1240
1279
  }
@@ -1244,8 +1283,10 @@ while ($true) {
1244
1283
  # output explicitly for each request; otherwise PowerShell's default
1245
1284
  # formatting subsystem can defer table output until the next request,
1246
1285
  # making commands such as Get-Location appear blank or duplicated.
1247
- $formattedOutput = Invoke-Expression -Command $commandText | Out-String -Width 4096
1248
- if (-not [string]::IsNullOrEmpty($formattedOutput)) {
1286
+ $result = Invoke-Expression -Command $commandText
1287
+
1288
+ if ($null -ne $result) {
1289
+ $formattedOutput = $result | Out-String -Width 120
1249
1290
  [Console]::Out.Write($formattedOutput)
1250
1291
  }
1251
1292
  }
@@ -1295,10 +1336,10 @@ while ($true) {
1295
1336
  def read_output():
1296
1337
  try:
1297
1338
  while True:
1298
- character = process.stdout.read(1) # type: ignore
1299
- if not character:
1339
+ line = process.stdout.readline() # type: ignore
1340
+ if not line:
1300
1341
  break
1301
- output_queue.put(character)
1342
+ output_queue.put(line)
1302
1343
  except (OSError, ValueError):
1303
1344
  pass
1304
1345
  finally:
@@ -1576,15 +1617,16 @@ def _run_shell_command_windows(command):
1576
1617
  stdin_is_tty = False
1577
1618
 
1578
1619
  try:
1579
- character = output_queue.get(timeout=0.04)
1620
+ chunk = output_queue.get(timeout=0.04)
1580
1621
  except queue.Empty:
1581
1622
  if shell.poll() is not None:
1582
1623
  return (None, collected, False)
1583
1624
  continue
1584
- if character is None:
1625
+
1626
+ if chunk is None:
1585
1627
  return (None, collected, False)
1586
1628
 
1587
- collected += character
1629
+ collected += chunk
1588
1630
  marker_pos = collected.find(marker)
1589
1631
  if marker_pos >= 0:
1590
1632
  stream_until(marker_pos)
@@ -1622,7 +1664,7 @@ def _run_shell_command_windows(command):
1622
1664
  except (BrokenPipeError, OSError, ValueError) as exc:
1623
1665
  close_shell()
1624
1666
  return f"Shell error: {exc}"
1625
-
1667
+
1626
1668
  def run_shell_command(command):
1627
1669
  global _shell_output_streamed
1628
1670
 
@@ -2733,7 +2775,7 @@ class Reaction:
2733
2775
  for i in self.conditions:
2734
2776
  self.equation += f"\t{i}: {self.conditions[i]}\n"
2735
2777
  def __str__(self):
2736
- return self.equation
2778
+ return self.equation.replace("1 ", "")
2737
2779
  def __repr__(self):
2738
2780
  return self.name
2739
2781
 
@@ -3176,7 +3218,7 @@ def balance_stoichiometry(reactants, products, reversible=None):
3176
3218
  def _execute_balance_command(text):
3177
3219
  text = text.split("//", 1)[0].rstrip()
3178
3220
  match = re.fullmatch(
3179
- r"\s*balance\s+(.+?)\s*(<->|->)\s*(.+?)\s*",
3221
+ r"\s*balance\s+((?:\S+?\s*)(?:\+\s*\S+?)*)\s*(<->|->)\s*(\S+)\s*",
3180
3222
  text,
3181
3223
  re.IGNORECASE,
3182
3224
  )
@@ -3637,7 +3679,13 @@ reaction_state = {
3637
3679
  }
3638
3680
 
3639
3681
  # Python variables persist for the whole session.
3640
- python_globals = {"__builtins__": __builtins__, "math": math, "error": None}
3682
+ python_globals = {
3683
+ "__builtins__": __builtins__,
3684
+ "math": math,
3685
+ "error": None,
3686
+ "warnings": None,
3687
+ "_": None,
3688
+ }
3641
3689
  STRUCTS = {}
3642
3690
  RECORDS = {}
3643
3691
  ENUMS = {}
@@ -4014,18 +4062,23 @@ def evaluate_python(code):
4014
4062
  tree = ast.parse(rewritten, mode="eval")
4015
4063
  except SyntaxError:
4016
4064
  module = ast.parse(rewritten, mode="exec")
4065
+
4017
4066
  for node in ast.walk(module):
4018
4067
  if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
4019
- if isinstance(node.ctx, ast.Store) and node.id.isidentifier() and not node.id.startswith("__chemql_"):
4068
+ if node.id.isidentifier() and not node.id.startswith("__chemql_"):
4020
4069
  CHEMQL_VARIABLES.add(node.id)
4070
+
4021
4071
  return _run_python_in_active_namespace(
4022
4072
  compile(module, "<embedded-python>", "exec"), "exec"
4023
4073
  )
4024
4074
 
4025
- return _run_python_in_active_namespace(
4075
+ result = _run_python_in_active_namespace(
4026
4076
  compile(tree, "<embedded-python>", "eval"), "eval"
4027
4077
  )
4028
4078
 
4079
+ python_globals["_"] = result
4080
+ return result
4081
+
4029
4082
 
4030
4083
  def is_python_block_header(text):
4031
4084
  """Recognize [for ...], [if ...], etc. without requiring a colon."""
@@ -4423,7 +4476,7 @@ def process_inline(text):
4423
4476
  return literal_value
4424
4477
 
4425
4478
  # Native dynamically typed variables: `let name = expression`.
4426
- let_match = re.fullmatch(r"let\s+([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)\s*=\s*(.+)", stripped, re.DOTALL)
4479
+ let_match = re.fullmatch(r"let\s+((?:[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)(?:::[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*)?)\s*=\s*(.+)", stripped, re.DOTALL)
4427
4480
  if let_match:
4428
4481
  name, expression = let_match.groups()
4429
4482
  value = evaluate_python(expression)
@@ -4457,7 +4510,7 @@ def process_inline(text):
4457
4510
 
4458
4511
  # A bare native variable is an expression that prints its value in the REPL.
4459
4512
  if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", stripped) and stripped in CHEMQL_VARIABLES:
4460
- return python_globals.get(stripped)
4513
+ return evaluate_python(stripped)
4461
4514
 
4462
4515
  # Attribute reads on ChemQL variables (e.g. `apple.price`).
4463
4516
  if re.fullmatch(r"[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)+", stripped):
@@ -4516,10 +4569,10 @@ def process_inline(text):
4516
4569
  # square brackets (for example `q+w`). Try Python expression parsing before
4517
4570
  # the query parser whenever the line references a known ChemQL variable.
4518
4571
  # This handles compact operators as well as spaced expressions.
4519
- known_expression_names = (CHEMQL_VARIABLES | {
4572
+ known_expression_names = CHEMQL_VARIABLES | {"_"} | {
4520
4573
  name for name in python_globals
4521
4574
  if isinstance(name, str) and name.isidentifier() and not name.startswith("__")
4522
- })
4575
+ }
4523
4576
  references_variable = any(
4524
4577
  re.search(r"\b" + re.escape(name) + r"\b", stripped)
4525
4578
  for name in known_expression_names
@@ -5393,6 +5446,17 @@ def process_lines(lines):
5393
5446
  signal_match = re.fullmatch(r"signal\s+(.+)", stripped, re.IGNORECASE)
5394
5447
  if signal_match:
5395
5448
  raise ChemQLSignal(evaluate_python(signal_match.group(1)))
5449
+ warn_match = re.fullmatch(r"warn\s+(.+)", stripped, re.IGNORECASE)
5450
+ if warn_match:
5451
+ warning = evaluate_python(warn_match.group(1))
5452
+ python_globals["warnings"] = warning
5453
+
5454
+ if not SUPPRESS_WARNINGS:
5455
+ message = f"Warning: {warning}"
5456
+ print(message if NO_ANSI else f"\033[33m{message}\033[0m")
5457
+
5458
+ i += 1
5459
+ continue
5396
5460
 
5397
5461
  # Definitions are processed in source order and affect only later lines.
5398
5462
  define_match = re.fullmatch(r"define\s+(" + _DEFINITION_NAME + r")\s+(.+)", stripped)
@@ -5697,9 +5761,9 @@ def process_lines(lines):
5697
5761
  i = end + 1
5698
5762
  continue
5699
5763
 
5700
- if line == "[break]":
5764
+ if line == "[break]" or line == "break":
5701
5765
  raise ChemQLBreak()
5702
- if line == "[continue]":
5766
+ if line == "[continue]" or line == "continue" or line == "skip":
5703
5767
  raise ChemQLContinue()
5704
5768
  if line == "view":
5705
5769
  view_periodic_table()
@@ -6014,11 +6078,10 @@ def execute(arguments, query_text=None, positions=None):
6014
6078
  if status != 0 and not message:
6015
6079
  message = f"Installation of {package_name} failed."
6016
6080
  return message or f"Installed {package_name}."
6017
-
6018
6081
  if arguments[0].lower() == "balance":
6019
6082
  try:
6020
6083
  return _execute_balance_command(
6021
- query_text if query_text is not None else shlex.join(arguments)
6084
+ query_text.replace("< - >", "<->").replace("- >", "->").replace("<= >", "<->").replace("= >", "->") if query_text is not None else shlex.join(arguments).replace("'<' - '>'", "<->").replace("- '>'", "->").replace("'<=' '>'", "<->").replace("= '>'", "->")
6022
6085
  )
6023
6086
  except ValueError as exc:
6024
6087
  return format_error(str(exc), query_text)
@@ -6516,7 +6579,8 @@ HELP = (
6516
6579
  " \x1b[95mrisk ... endrisk \x1b[0m \x1b[90m│\x1b[0m Run a protected block\n"
6517
6580
  " \x1b[95mfix \x1b[0m \x1b[90m│\x1b[0m Handle an error from the risk block\n"
6518
6581
  " \x1b[95mcleanup \x1b[0m \x1b[90m│\x1b[0m Run cleanup whether successful or not\n"
6519
- " \x1b[95msignal \"message\" \x1b[0m \x1b[90m│\x1b[0m Raise a ChemQL signal\n"
6582
+ " \x1b[95msignal \"message\" \x1b[0m \x1b[90m│\x1b[0m Raise a ChemQL signal, latest signal is stored in error\n"
6583
+ " \x1b[95mwarn \"message\" \x1b[0m \x1b[90m│\x1b[0m Raise a warning, latest warning is stored in warnings\n"
6520
6584
  " \x1b[95merror \x1b[0m \x1b[90m│\x1b[0m Inspect the error type in a fix block\n"
6521
6585
  " \x1b[95mrisk\x1b[0m\n"
6522
6586
  " \x1b[95msignal \"Something went wrong\"\x1b[0m\n"
@@ -7269,6 +7333,8 @@ def _download_package(name, version=None, force_replace=False):
7269
7333
  release_info = _api_object(_http_json(f"{endpoint}/latest"))
7270
7334
  else:
7271
7335
  release_info = _api_object(_http_json(f"{endpoint}/{urllib.parse.quote(version, safe='')}"))
7336
+ if release_info.get("safe") != 1:
7337
+ raise RuntimeError("Package was too unsafe to download")
7272
7338
  actual_version = str(release_info.get("version") or release_info.get("version_number") or version or "")
7273
7339
  if not actual_version or not re.fullmatch(r"[A-Za-z0-9.+_-]+", actual_version):
7274
7340
  raise RuntimeError("Registry response did not include a valid package version.")
@@ -7357,6 +7423,8 @@ def _package_command(args):
7357
7423
  return _download_package(args.name, args.version)
7358
7424
  if args.subcommand == "upgrade":
7359
7425
  return _download_package(args.name, args.version, force_replace=True)
7426
+ if args.subcommand == "upload":
7427
+ return _upload_project()
7360
7428
  if args.subcommand == "search":
7361
7429
  data = _http_json(f"{_registry_url()}/api/v1/search?q={urllib.parse.quote(args.query)}")
7362
7430
  results = data.get("results", data.get("packages", [])) if isinstance(data, dict) else data
@@ -7403,6 +7471,95 @@ def _package_command(args):
7403
7471
  return 1
7404
7472
  return 2
7405
7473
 
7474
+ def _upload_project(directory="."):
7475
+ root = Path(directory).expanduser().resolve()
7476
+ manifest_path = root / "cqlpackage.json"
7477
+
7478
+ if not manifest_path.is_file():
7479
+ raise RuntimeError(
7480
+ f"No cqlpackage.json found in {root}"
7481
+ )
7482
+
7483
+ with manifest_path.open("r", encoding="utf-8") as stream:
7484
+ package = json.load(stream)
7485
+
7486
+ if not isinstance(package, dict):
7487
+ raise RuntimeError("Invalid package manifest.")
7488
+
7489
+ name = package.get("name")
7490
+ version = str(package.get("version", "")).strip()
7491
+
7492
+ if not isinstance(name, str) or not _safe_package_name(name):
7493
+ raise RuntimeError("Invalid package name.")
7494
+
7495
+ if not version or not re.fullmatch(r"[A-Za-z0-9.+_-]+", version):
7496
+ raise RuntimeError("Invalid package version.")
7497
+
7498
+ archive_path = root / "dist" / f"{name}-{version}.tar.gz"
7499
+
7500
+ if not archive_path.is_file():
7501
+ raise RuntimeError(
7502
+ f"Package archive not found: {archive_path}\n"
7503
+ "Run `chemql build` first."
7504
+ )
7505
+
7506
+ payload = archive_path.read_bytes()
7507
+
7508
+ if len(payload) > 10 * 1024 * 1024:
7509
+ raise RuntimeError(
7510
+ "Package archive exceeds the registry's 10 MB upload limit."
7511
+ )
7512
+
7513
+ boundary = "----ChemQL" + os.urandom(16).hex()
7514
+
7515
+ body = (
7516
+ f"--{boundary}\r\n"
7517
+ f'Content-Disposition: form-data; name="file"; '
7518
+ f'filename="{archive_path.name}"\r\n'
7519
+ f"Content-Type: application/gzip\r\n\r\n"
7520
+ ).encode("utf-8")
7521
+
7522
+ body += payload
7523
+ body += f"\r\n--{boundary}--\r\n".encode("utf-8")
7524
+
7525
+ endpoint = f"{_registry_url()}/api/v1/upload"
7526
+
7527
+ request = urllib.request.Request(
7528
+ endpoint,
7529
+ data=body,
7530
+ headers={
7531
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
7532
+ "Accept": "application/json",
7533
+ "User-Agent": f"ChemQL/{__version__}",
7534
+ },
7535
+ method="POST",
7536
+ )
7537
+
7538
+ try:
7539
+ with urllib.request.urlopen(request, timeout=60) as response:
7540
+ result = json.loads(response.read(1_000_001))
7541
+ except urllib.error.HTTPError as exc:
7542
+ try:
7543
+ result = json.loads(exc.read(1_000_001))
7544
+ message = result.get("error", exc.reason)
7545
+ except (json.JSONDecodeError, UnicodeDecodeError):
7546
+ message = exc.reason
7547
+
7548
+ raise RuntimeError(
7549
+ f"Upload failed (HTTP {exc.code}): {message}"
7550
+ ) from exc
7551
+ except urllib.error.URLError as exc:
7552
+ raise RuntimeError(
7553
+ f"Could not connect to ChemQL registry: {exc.reason}"
7554
+ ) from exc
7555
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
7556
+ raise RuntimeError(
7557
+ "Registry returned an invalid response."
7558
+ ) from exc
7559
+
7560
+ print(f"Published {name} v{version}!")
7561
+ print(result.get("message", "Upload successful."))
7562
+ return 0
7406
7563
 
7407
7564
  def _build_project(directory="."):
7408
7565
  """Build a ChemQL package without requiring a package-local build.py."""
@@ -7539,6 +7696,7 @@ def main(ANSI: bool = True):
7539
7696
  subparsers = parser.add_subparsers(dest="subcommand")
7540
7697
 
7541
7698
  p_install = subparsers.add_parser("install", help="Install a package from the ChemQL registry")
7699
+ subparsers.add_parser("upload", help="Upload the current ChemQL package to the registry")
7542
7700
  p_install.add_argument("name")
7543
7701
  p_install.add_argument("--version", default=None)
7544
7702
 
@@ -7580,6 +7738,11 @@ def main(ANSI: bool = True):
7580
7738
  action="store_true",
7581
7739
  help="Strip ANSI escape sequences from output.",
7582
7740
  )
7741
+ parser.add_argument(
7742
+ "--suppress-warnings",
7743
+ action="store_true",
7744
+ help="Suppress warning messages.",
7745
+ )
7583
7746
 
7584
7747
  parser.add_argument(
7585
7748
  "-o", "--output",
@@ -7625,8 +7788,10 @@ def main(ANSI: bool = True):
7625
7788
  )
7626
7789
  sys.exit(upgrade_package(version_target, args.force))
7627
7790
 
7628
- global NO_ANSI
7791
+ global NO_ANSI, SUPPRESS_WARNINGS
7792
+
7629
7793
  NO_ANSI = not ANSI or args.no_ansi or bool(args.output)
7794
+ SUPPRESS_WARNINGS = args.suppress_warnings
7630
7795
 
7631
7796
  if args.output and args.command is None and args.file is None:
7632
7797
  parser.error("-o/--output requires -c/--command or -f/--file")
chemql/codecql.py CHANGED
@@ -9,8 +9,9 @@ import sys
9
9
  import shutil
10
10
  import subprocess
11
11
  import time
12
+ from .highlighting import HighlightProfiles
12
13
 
13
- VERSION = "0.0.10"
14
+ VERSION = "0.0.11"
14
15
 
15
16
 
16
17
  def _write_system_clipboard(text):
@@ -320,7 +321,7 @@ AUTO_INDENT = re.compile(
320
321
  )
321
322
 
322
323
 
323
- def _token_style(kind, value):
324
+ def _token_style(kind, value, profiles=None, filename=None):
324
325
  if kind == "comment":
325
326
  return HIGHLIGHT_STYLES["comment"]
326
327
  if kind == "string":
@@ -334,6 +335,11 @@ def _token_style(kind, value):
334
335
  if kind == "delimiter":
335
336
  return HIGHLIGHT_STYLES["command"] if value == "?" else HIGHLIGHT_STYLES["delimiter"]
336
337
  word = value.casefold()
338
+ if profiles and filename:
339
+ configured_style = profiles.get_keyword_style(filename, word)
340
+
341
+ if configured_style:
342
+ return parse_highlight_style(configured_style)
337
343
  if word in {"true", "false", "none"}:
338
344
  category = "constant"
339
345
  elif word in {"enum", "struct", "record"}:
@@ -367,6 +373,34 @@ MANIFEST_TOKEN_RE = re.compile(
367
373
  r"|(?P<delimiter>[\[\]{}:,.])",
368
374
  re.IGNORECASE,
369
375
  )
376
+ def load_highlighting_profiles():
377
+ profiles = HighlightProfiles()
378
+
379
+ main_config = os.path.join(
380
+ os.path.dirname(os.path.abspath(__file__)),
381
+ "highlighting.json",
382
+ )
383
+
384
+ local_config = os.path.join(
385
+ os.getcwd(),
386
+ "highlighting.json",
387
+ )
388
+
389
+ loaded_paths = set()
390
+
391
+ for path in (main_config, local_config):
392
+ path = os.path.abspath(path)
393
+
394
+ # Avoid loading the same file twice.
395
+ if path in loaded_paths:
396
+ continue
397
+
398
+ loaded_paths.add(path)
399
+
400
+ if os.path.isfile(path):
401
+ profiles.load(path)
402
+
403
+ return profiles
370
404
 
371
405
  def _apply_manifest_syntax_highlighting(text, filename):
372
406
  """Highlight JSON/TOML package manifests with the editor's existing theme."""
@@ -421,8 +455,68 @@ def _apply_manifest_syntax_highlighting(text, filename):
421
455
  parts.append(text[last_end:])
422
456
  return "".join(parts)
423
457
 
458
+ def parse_highlight_style(style):
459
+ parts = style.split()
460
+ codes = []
461
+
462
+ named_styles = {
463
+ "keyword": HIGHLIGHT_STYLES["keyword"],
464
+ "command": HIGHLIGHT_STYLES["command"],
465
+ "operator": HIGHLIGHT_STYLES["operator"],
466
+ "string": HIGHLIGHT_STYLES["string"],
467
+ "comment": HIGHLIGHT_STYLES["comment"],
468
+ "number": HIGHLIGHT_STYLES["number"],
469
+ "constant": HIGHLIGHT_STYLES["constant"],
470
+ "type": HIGHLIGHT_STYLES["type"],
471
+ "source": HIGHLIGHT_STYLES["source"],
472
+ "condition": HIGHLIGHT_STYLES["condition"],
473
+ "property": HIGHLIGHT_STYLES["property"],
474
+ "builtin": HIGHLIGHT_STYLES["builtin"],
475
+ }
476
+
477
+ formatting = {
478
+ "bold": "1",
479
+ "dim": "2",
480
+ "italic": "3",
481
+ "underline": "4",
482
+ "reverse": "7",
483
+ "strike": "9",
484
+ }
485
+
486
+ for part in parts:
487
+ part = part.casefold()
424
488
 
425
- def apply_syntax_highlighting(text, filename=None):
489
+ if part in named_styles:
490
+ # Existing theme styles are already ANSI escape sequences.
491
+ codes.append(named_styles[part])
492
+
493
+ elif part in formatting:
494
+ codes.append(f"\033[{formatting[part]}m")
495
+
496
+ elif re.fullmatch(r"#[0-9a-f]{6}", part):
497
+ r, g, b = (
498
+ int(part[i:i + 2], 16)
499
+ for i in (1, 3, 5)
500
+ )
501
+ codes.append(f"\033[38;2;{r};{g};{b}m")
502
+
503
+ elif part.startswith("bg:#") and re.fullmatch(
504
+ r"bg:#[0-9a-f]{6}", part
505
+ ):
506
+ color = part[3:]
507
+ r, g, b = (
508
+ int(color[i:i + 2], 16)
509
+ for i in (1, 3, 5)
510
+ )
511
+ codes.append(f"\033[48;2;{r};{g};{b}m")
512
+
513
+ else:
514
+ raise ValueError(f"Unknown highlight style component: {part}")
515
+
516
+ return "".join(codes)
517
+
518
+
519
+ def apply_syntax_highlighting(text, filename=None, profiles=None):
426
520
  if filename:
427
521
  manifest_highlighting = _apply_manifest_syntax_highlighting(text, filename)
428
522
  if manifest_highlighting is not None:
@@ -433,7 +527,7 @@ def apply_syntax_highlighting(text, filename=None):
433
527
  parts.append(text[last_end:match.start()])
434
528
  kind = match.lastgroup
435
529
  token = match.group()
436
- style = _token_style(kind, token) if kind else ""
530
+ style = _token_style(kind, token, profiles, filename) if kind else ""
437
531
  if kind == "identifier" and re.search(
438
532
  r"\b(?:enum|struct|record|namespace)\s+$", text[:match.start()], re.IGNORECASE
439
533
  ):
@@ -475,6 +569,7 @@ class Editor:
475
569
  content = f.read().splitlines()
476
570
  self.lines = content if content else [""]
477
571
  self.saved_lines = tuple(self.lines)
572
+ self.highlight_profiles = load_highlighting_profiles()
478
573
 
479
574
  def run(self):
480
575
  sys.stdout.write("\x1b[?1049h\x1b[?2004h")
@@ -564,7 +659,7 @@ class Editor:
564
659
  current = (match_start, match_end) == self.current_search_match
565
660
  overlays.append((start, end, "\x1b[7m" if current else "\x1b[43m", 1))
566
661
  if not overlays:
567
- return apply_syntax_highlighting(visible_text, self.filename)
662
+ return apply_syntax_highlighting(visible_text, self.filename, self.highlight_profiles)
568
663
 
569
664
  boundaries = {line_start, line_end}
570
665
  for start, end, _, _ in overlays:
@@ -589,7 +684,11 @@ class Editor:
589
684
  style = max(active, key=lambda overlay: overlay[3])[2]
590
685
  parts.extend((style, segment, RESET))
591
686
  else:
592
- parts.append(apply_syntax_highlighting(segment, self.filename))
687
+ parts.append(
688
+ apply_syntax_highlighting(
689
+ segment, self.filename, self.highlight_profiles
690
+ )
691
+ )
593
692
  return "".join(parts)
594
693
 
595
694
  def _keep_cursor_visible(self, height, width, visual_cursor_x):
@@ -1317,10 +1416,43 @@ def main(argv: list[str] | None = None) -> None:
1317
1416
  )
1318
1417
  parser.add_argument("-V", "--version", action="version", version=f"codecql {VERSION}")
1319
1418
  parser.add_argument("file", nargs="?", help="ChemQL file to edit")
1419
+ parser.add_argument(
1420
+ "--main-highlighting",
1421
+ action="store_true",
1422
+ help="Open CodecQL's main highlighting configuration",
1423
+ )
1424
+
1425
+ parser.add_argument(
1426
+ "--local-highlighting",
1427
+ action="store_true",
1428
+ help="Open the current directory's highlighting configuration",
1429
+ )
1320
1430
  arguments = parser.parse_args(argv)
1321
- if not arguments.file:
1431
+ if arguments.main_highlighting and arguments.local_highlighting:
1432
+ parser.error("Choose only one highlighting configuration")
1433
+
1434
+ if arguments.main_highlighting:
1435
+ filename = os.path.join(
1436
+ os.path.dirname(os.path.abspath(__file__)),
1437
+ "highlighting.json",
1438
+ )
1439
+
1440
+ elif arguments.local_highlighting:
1441
+ filename = os.path.join(os.getcwd(), "highlighting.json")
1442
+
1443
+ else:
1444
+ filename = arguments.file
1445
+
1446
+ if not filename:
1322
1447
  parser.error("the following arguments are required: file")
1323
- Editor(arguments.file).run()
1448
+
1449
+ if arguments.main_highlighting or arguments.local_highlighting:
1450
+ if not os.path.exists(filename):
1451
+ with open(filename, "w", encoding="utf-8") as f:
1452
+ json.dump({}, f, indent=4)
1453
+ f.write("\n")
1454
+
1455
+ Editor(filename).run()
1324
1456
 
1325
1457
  if __name__ == "__main__":
1326
1458
  main()