pyqt-code-editor 0.0.1__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.
Files changed (73) hide show
  1. pyqt_code_editor/__init__.py +5 -0
  2. pyqt_code_editor/_settings.py +232 -0
  3. pyqt_code_editor/app.py +438 -0
  4. pyqt_code_editor/code_editors/__init__.py +1 -0
  5. pyqt_code_editor/code_editors/code_editors.py +33 -0
  6. pyqt_code_editor/code_editors/languages/__init__.py +0 -0
  7. pyqt_code_editor/code_editors/languages/generic.py +11 -0
  8. pyqt_code_editor/code_editors/languages/markdown.py +11 -0
  9. pyqt_code_editor/code_editors/languages/python.py +13 -0
  10. pyqt_code_editor/code_editors/languages/text.py +9 -0
  11. pyqt_code_editor/components/__init__.py +0 -0
  12. pyqt_code_editor/components/editor_panel.py +399 -0
  13. pyqt_code_editor/components/find_in_files.py +199 -0
  14. pyqt_code_editor/components/jupyter_console.py +487 -0
  15. pyqt_code_editor/components/project_explorer.py +515 -0
  16. pyqt_code_editor/components/settings_panel.py +162 -0
  17. pyqt_code_editor/components/sigmund.py +75 -0
  18. pyqt_code_editor/components/workspace_explorer.py +162 -0
  19. pyqt_code_editor/mixins/__init__.py +15 -0
  20. pyqt_code_editor/mixins/auto_pair.py +201 -0
  21. pyqt_code_editor/mixins/base.py +142 -0
  22. pyqt_code_editor/mixins/check.py +94 -0
  23. pyqt_code_editor/mixins/comment.py +91 -0
  24. pyqt_code_editor/mixins/complete.py +482 -0
  25. pyqt_code_editor/mixins/execute.py +182 -0
  26. pyqt_code_editor/mixins/file_link.py +166 -0
  27. pyqt_code_editor/mixins/line_number.py +206 -0
  28. pyqt_code_editor/mixins/python_auto_indent.py +239 -0
  29. pyqt_code_editor/mixins/search_replace.py +396 -0
  30. pyqt_code_editor/mixins/shortcuts.py +228 -0
  31. pyqt_code_editor/mixins/smart_backspace_delete.py +120 -0
  32. pyqt_code_editor/mixins/symbols.py +45 -0
  33. pyqt_code_editor/mixins/theme.py +99 -0
  34. pyqt_code_editor/mixins/widgets/__init__.py +0 -0
  35. pyqt_code_editor/mixins/zoom.py +51 -0
  36. pyqt_code_editor/python_utils.py +936 -0
  37. pyqt_code_editor/signal_router.py +116 -0
  38. pyqt_code_editor/syntax_highlighters/__init__.py +0 -0
  39. pyqt_code_editor/syntax_highlighters/languages/__init__.py +0 -0
  40. pyqt_code_editor/syntax_highlighters/languages/generic.py +86 -0
  41. pyqt_code_editor/syntax_highlighters/languages/python.py +62 -0
  42. pyqt_code_editor/syntax_highlighters/syntax_highlighter.py +26 -0
  43. pyqt_code_editor/themes.py +36 -0
  44. pyqt_code_editor/utils.py +96 -0
  45. pyqt_code_editor/watchdog.py +141 -0
  46. pyqt_code_editor/widgets/__init__.py +6 -0
  47. pyqt_code_editor/widgets/calltip_widget.py +44 -0
  48. pyqt_code_editor/widgets/completion_popup.py +89 -0
  49. pyqt_code_editor/widgets/dock.py +15 -0
  50. pyqt_code_editor/widgets/quick_open_dialog.py +196 -0
  51. pyqt_code_editor/widgets/quick_open_file_dialog.py +32 -0
  52. pyqt_code_editor/widgets/quick_symbol_dialog.py +13 -0
  53. pyqt_code_editor/widgets/search_replace_frame.py +128 -0
  54. pyqt_code_editor/widgets/tab_splitter.py +22 -0
  55. pyqt_code_editor/widgets/tabbed_editor.py +351 -0
  56. pyqt_code_editor/worker/__init__.py +0 -0
  57. pyqt_code_editor/worker/languages/__init__.py +0 -0
  58. pyqt_code_editor/worker/languages/generic.py +15 -0
  59. pyqt_code_editor/worker/languages/python.py +17 -0
  60. pyqt_code_editor/worker/manager.py +113 -0
  61. pyqt_code_editor/worker/process.py +128 -0
  62. pyqt_code_editor/worker/providers/__init__.py +1 -0
  63. pyqt_code_editor/worker/providers/codestral.py +56 -0
  64. pyqt_code_editor/worker/providers/jedi.py +115 -0
  65. pyqt_code_editor/worker/providers/ruff.py +45 -0
  66. pyqt_code_editor/worker/providers/sigmund.py +29 -0
  67. pyqt_code_editor/worker/providers/symbol.py +78 -0
  68. pyqt_code_editor/worker/settings.py +1 -0
  69. pyqt_code_editor-0.0.1.dist-info/METADATA +69 -0
  70. pyqt_code_editor-0.0.1.dist-info/RECORD +73 -0
  71. pyqt_code_editor-0.0.1.dist-info/WHEEL +4 -0
  72. pyqt_code_editor-0.0.1.dist-info/entry_points.txt +4 -0
  73. pyqt_code_editor-0.0.1.dist-info/licenses/COPYING +674 -0
@@ -0,0 +1,936 @@
1
+ import re
2
+ from . import settings
3
+ import logging
4
+ logging.basicConfig(level=logging.INFO, force=True)
5
+ logger = logging.getLogger(__name__)
6
+
7
+
8
+ def get_leading_spaces(line: str) -> int:
9
+ """
10
+ Return the number of leading spaces in line.
11
+ """
12
+ return len(line) - len(line.lstrip(' '))
13
+
14
+
15
+ def parse_brackets(code: str):
16
+ """
17
+ Parse all lines of code, ignoring quoted strings in a simple way,
18
+ and return:
19
+ bracket_stack: A list of unclosed brackets in order of appearance,
20
+ each as (bracket_char, line_index, col_index).
21
+ bracket_closings: A dict mapping close_line_idx -> (open_line_idx, open_line_indent, open_bracket).
22
+ """
23
+ open_to_close = {'(': ')', '[': ']', '{': '}'}
24
+ close_to_open = {')': '(', ']': '[', '}': '{'}
25
+
26
+ # A naive bracket parser that ignores strings by removing them first
27
+ # (this is simplistic; real logic may need to handle triple quotes, etc.)
28
+ def remove_strings(line: str) -> str:
29
+ return re.sub(r'(".*?"|\'.*?\')', '', line)
30
+
31
+ lines = code.split("\n")
32
+ bracket_stack = []
33
+ bracket_closings = {}
34
+
35
+ for i, raw_line in enumerate(lines):
36
+ # remove quoted strings
37
+ stripped_line = remove_strings(raw_line)
38
+
39
+ j = 0
40
+ while j < len(stripped_line):
41
+ ch = stripped_line[j]
42
+ if ch in open_to_close:
43
+ bracket_stack.append((ch, i, j))
44
+ elif ch in close_to_open:
45
+ # If there's something on the stack and it matches, pop it
46
+ if bracket_stack and bracket_stack[-1][0] == close_to_open[ch]:
47
+ open_bracket_char, open_line_idx, open_col_idx = bracket_stack.pop()
48
+ # record the closing bracket
49
+ bracket_closings[i] = (open_line_idx, get_leading_spaces(lines[open_line_idx]), open_bracket_char)
50
+ j += 1
51
+
52
+ return bracket_stack, bracket_closings
53
+
54
+
55
+ def _get_unclosed_open_idx(code : str, open_char : str,
56
+ close_char : str) -> int | None:
57
+ """Returns the index of the last unclosed unopening character, such as an
58
+ opening parenthesis, or None if no unclosed opening character was found.
59
+ """
60
+ # We only consider code from the end of the last code comment or the last
61
+ # quote, whatever comes last. This is a cheap way to avoid the auto indenter
62
+ # from being confused by parentheses in strings or comments.
63
+ last_quote = max(code.rfind("'"), code.rfind('"'))
64
+ last_comment = code.rfind('#')
65
+ if last_comment >= 0:
66
+ last_comment_end = code[last_comment:].find('\n')
67
+ if last_comment_end >= 0:
68
+ last_comment += last_comment_end
69
+ search_start = max(0, last_quote, last_comment)
70
+ if search_start:
71
+ code = code[search_start:]
72
+ n_open_required = 1
73
+ while n_open_required:
74
+ close_idx = code.rfind(close_char)
75
+ open_idx = code.rfind(open_char)
76
+ if close_idx < 0 and open_idx < 0:
77
+ break
78
+ if open_idx > close_idx:
79
+ n_open_required -= 1
80
+ code = code[:open_idx]
81
+ else:
82
+ n_open_required += 1
83
+ code = code[:close_idx]
84
+ if open_idx < 0:
85
+ return 0
86
+ return open_idx + search_start
87
+
88
+
89
+ def _line_col_from_idx(full_text, char_index):
90
+ """
91
+ Return the 0-based (line, column) pair in `full_text` for the given `char_index`.
92
+ If char_index is out of range, this function raises an IndexError.
93
+ """
94
+ if not (0 <= char_index < len(full_text)):
95
+ raise IndexError("char_index out of range.")
96
+
97
+ line = 0
98
+ column = 0
99
+ idx = 0
100
+
101
+ for ch in full_text:
102
+ if idx == char_index:
103
+ return (line, column)
104
+ if ch == '\n':
105
+ line += 1
106
+ column = 0
107
+ else:
108
+ column += 1
109
+ idx += 1
110
+
111
+ # If char_index matches len(full_text)-1, return the final position:
112
+ return (line, column)
113
+
114
+
115
+ def _indent_inside_unclosed_call_def_or_class(code: str, char_idx : int) -> int:
116
+ """
117
+ Return after opening parenthesis as part of function call should trigger indent
118
+
119
+ ```
120
+ function(>
121
+ |
122
+ ```
123
+
124
+ Return after parenthesis after function definition should trigger indent
125
+
126
+ ```
127
+ def test(>
128
+ |
129
+ ```
130
+
131
+ Return after parenthesis after class definition should trigger indent
132
+
133
+ ```
134
+ class Test(>
135
+ |
136
+ ```
137
+
138
+ Return after opening parenthesis and one or more arguments as part of function call should trigger matching indent to first argument
139
+
140
+ ```
141
+ function(arg1,>
142
+ |
143
+ ```
144
+
145
+ Return after parenthesis and one or more arguments after function definition should trigger matching indent to first argument
146
+
147
+ ```
148
+ def test(arg1,>
149
+ |
150
+ ```
151
+
152
+ Return after parenthesis and one or more arguments after class definition should trigger matching indent to first argument
153
+
154
+ ```
155
+ class Test(object,>
156
+ |
157
+ ```
158
+
159
+ Return after opening parenthesis and one or more arguments on the next line as part of function call should trigger matching indent to first argument
160
+
161
+ ```
162
+ function(
163
+ arg1,>
164
+ |
165
+ ```
166
+
167
+ Return after parenthesis and one or more arguments on the next line after function definition should trigger matching indent to first argument
168
+
169
+ ```
170
+ def test(
171
+ arg1,>
172
+ |
173
+ ```
174
+
175
+ Return after parenthesis and one or more arguments after class definition should trigger matching indent to first argument
176
+
177
+ ```
178
+ class Test(
179
+ object,>
180
+ |
181
+ ```
182
+
183
+ Return after opening parenthesis should also work in nested situations
184
+
185
+ ```
186
+ class Test:
187
+ def test(>
188
+ |
189
+ ```
190
+
191
+ Return after opening parenthesis with arguments should also work in nested situations
192
+
193
+ ```
194
+ class Test:
195
+ def test(arg1,>
196
+ |
197
+ ```
198
+
199
+ Return after opening parenthesis with arguments should also work in nested situations
200
+
201
+ ```
202
+ class Test:
203
+ def test(
204
+ arg1,>
205
+ |
206
+ ```
207
+
208
+ Return after opening parenthesis should also work with irrelevant parentheses
209
+
210
+ ```
211
+ # 1) a list item
212
+ class Test:
213
+ def test(>
214
+ |
215
+ ```
216
+
217
+ Return after opening parenthesis should also work with irrelevant parentheses
218
+
219
+ ```
220
+ # (1 a list item
221
+ class Test:
222
+ def test(>
223
+ |
224
+ ```
225
+ """
226
+ lines = code.splitlines()
227
+ if not lines:
228
+ return 0
229
+
230
+ line_idx, col_idx = _line_col_from_idx(code, char_idx)
231
+ # Get the line where the last unclosed parenthesis is
232
+ open_line = lines[line_idx]
233
+ # Collect text after '(' in that line
234
+ text_after_paren = open_line[col_idx + 1:].rstrip()
235
+ # If there's no text after '(' on the same line, just indent by tab
236
+ if not text_after_paren:
237
+ # Get indentation of open_line
238
+ open_line_indent = len(open_line) - len(open_line.lstrip())
239
+ return open_line_indent + settings.tab_width
240
+ # Otherwise, align with the first non-whitespace character after '('
241
+ prefix = open_line[: col_idx + 1]
242
+ after_paren_offset = 0
243
+ for char in open_line[col_idx + 1:]:
244
+ if char.isspace():
245
+ after_paren_offset += 1
246
+ else:
247
+ break
248
+
249
+ return len(prefix) + after_paren_offset
250
+
251
+
252
+ def _indent_after_block_opener(code: str) -> int:
253
+ """
254
+ Return after function definition should trigger indent
255
+
256
+ ```
257
+ def test():>
258
+ |
259
+ ```
260
+
261
+ Return after class definition should trigger indent
262
+
263
+ ```
264
+ class Test:>
265
+ |
266
+ ```
267
+
268
+ Return after class definition with inheritance should trigger indent
269
+
270
+ ```
271
+ class Test(object):>
272
+ |
273
+ ```
274
+
275
+ Return after colon after function definition should dedent to function level, regardless of the indentation of the current line
276
+
277
+ ```
278
+ def test(arg1,
279
+ arg2):>
280
+ |
281
+ ```
282
+
283
+ Return after colon after class definition should dedent to class level, regardless of the indentation of the current line
284
+
285
+ ```
286
+ class Test(object1,
287
+ object2):>
288
+ |
289
+ ```
290
+
291
+ Return after function definition should also work with irrelevant parentheses
292
+
293
+ ```
294
+ # 1) a list item
295
+ class Test:
296
+ def test():>
297
+ |
298
+ ```
299
+
300
+ Return after function definition should also work with irrelevant parentheses
301
+
302
+ ```
303
+ # (1 a list item
304
+ class Test:
305
+ def test():>
306
+ |
307
+
308
+ Return after colon after while should trigger indent:
309
+
310
+ ```
311
+ while True:>
312
+ |
313
+ ```
314
+
315
+ Return after colon after for should trigger indent:
316
+
317
+ ```
318
+ for i in range(10):>
319
+ |
320
+ ```
321
+
322
+ Return after if should trigger indent:
323
+
324
+ ```
325
+ if x == y:>
326
+ |
327
+ ```
328
+
329
+ Return after else should trigger indent:
330
+
331
+ ```
332
+ else:>
333
+ |
334
+ ```
335
+
336
+ Return after elif should trigger indent:
337
+
338
+ ```
339
+ elif x == y:>
340
+ |
341
+ ```
342
+
343
+ Return after colon after with should trigger indent:
344
+
345
+ ```
346
+ with context:>
347
+ |
348
+ ```
349
+
350
+ Return after colon after with ... as should trigger indent:
351
+
352
+ ```
353
+ with context as obj:>
354
+ |
355
+ ```
356
+
357
+ Return after try should trigger indent:
358
+
359
+ ```
360
+ try:>
361
+ |
362
+ ```
363
+
364
+ Return after except should trigger indent:
365
+
366
+ ```
367
+ except:>
368
+ |
369
+ ```
370
+
371
+ Return after except ... some exception class should trigger indent:
372
+
373
+ ```
374
+ except Exception:>
375
+ |
376
+ ```
377
+ Return after except ... as should trigger indent:
378
+
379
+ ```
380
+ except Exception as e:>
381
+ |
382
+ ```
383
+
384
+ Return after finally should trigger indent:
385
+
386
+ ```
387
+ finally:>
388
+ |
389
+ ```
390
+
391
+ Indent after function definition should also work in nested situations:
392
+
393
+ ```
394
+ class Test:
395
+ def test():>
396
+ |
397
+ ```
398
+
399
+ Indent after if should also work in nested situations:
400
+
401
+ ```
402
+ def test():
403
+ if x == y:>
404
+ |
405
+ ```
406
+ """
407
+ BLOCK_KEYWORDS = (
408
+ "def", "class", "if", "elif", "else", "while",
409
+ "for", "with", "try", "except", "finally"
410
+ )
411
+
412
+ def is_block_opener(line: str) -> bool:
413
+ stripped = line.lstrip()
414
+ lower_stripped = stripped.lower()
415
+ for kw in BLOCK_KEYWORDS:
416
+ if lower_stripped.startswith(kw) and (
417
+ len(stripped) == len(kw)
418
+ or stripped[len(kw)] in (' ', '(', ':')
419
+ ):
420
+ return True
421
+ return False
422
+
423
+ lines = code.splitlines()
424
+ if not lines:
425
+ return 0
426
+
427
+ last_line = lines[-1]
428
+ trimmed_line = last_line.rstrip()
429
+ if not trimmed_line.endswith(':'):
430
+ # just return current line's indentation
431
+ return get_leading_spaces(last_line)
432
+
433
+ # If it DOES end with a colon, we see if we can locate the block opener line
434
+ block_opener_idx = len(lines) - 1
435
+ for i in range(len(lines) - 1, -1, -1):
436
+ if is_block_opener(lines[i]):
437
+ block_opener_idx = i
438
+ break
439
+
440
+ block_opener_indent = get_leading_spaces(lines[block_opener_idx])
441
+ return block_opener_indent + settings.tab_width
442
+
443
+
444
+ def _indent_inside_uncloded_list_tuple_set_or_dict(code: str, char_idx: int) -> int:
445
+ """
446
+ Return after opening of list should trigger matching indent to first element
447
+
448
+ ```
449
+ l = [>
450
+ |
451
+ ```
452
+
453
+ Return after opening of tuple should trigger matching indent to first element
454
+
455
+ ```
456
+ t = (>
457
+ |
458
+ ```
459
+
460
+ Return after opening of set should trigger matching indent to first element
461
+
462
+ ```
463
+ s = {>
464
+ |
465
+ ```
466
+
467
+ Return after opening of dict should trigger matching indent to first element
468
+
469
+ ```
470
+ d = {>
471
+ |
472
+ ```
473
+
474
+ Return after opening of list with one or more elements should trigger matching indent to first element
475
+
476
+ ```
477
+ l = [item1,>
478
+ |
479
+ ```
480
+
481
+ Return after opening of tuple with one or more elements should trigger matching indent to first element
482
+
483
+ ```
484
+ t = (item1,>
485
+ |
486
+ ```
487
+
488
+ Return after opening of set with one or more elements should trigger matching indent to first element
489
+
490
+ ```
491
+ s = {item1,>
492
+ |
493
+ ```
494
+
495
+ Return after opening of dict with one or more elements should trigger matching indent to first element
496
+
497
+ ```
498
+ d = {key1:val1,>
499
+ |
500
+ ```
501
+
502
+ Return after opening of list with one or more elements on the next line should trigger matching indent to first element
503
+
504
+ ```
505
+ l = [
506
+ item1,>
507
+ |
508
+ ```
509
+
510
+ Return after opening of tuple with one or more elements on the next line should trigger matching indent to first element
511
+
512
+ ```
513
+ t = (
514
+ item1,>
515
+ |
516
+ ```
517
+
518
+ Return after opening of set with one or more elements on the next line should trigger matching indent to first element
519
+
520
+ ```
521
+ s = {
522
+ item1,>
523
+ |
524
+ ```
525
+
526
+ Return after opening of dict with one or more elements on the next line should trigger matching indent to first element
527
+
528
+ ```
529
+ d = {
530
+ key1:val1,>
531
+ |
532
+ ```
533
+
534
+ Return after opening of list should also work in nested situations:
535
+
536
+ ```
537
+ def test():
538
+ l = [>
539
+ |
540
+ ```
541
+
542
+ Return after opening of list with one or more elements should also work in nested situations:
543
+
544
+ ```
545
+ def test():
546
+ l = [item1,>
547
+ |
548
+ ```
549
+
550
+ Return after opening of list with one or more elements on the next line should also work in nested situations:
551
+
552
+ ```
553
+ def test():
554
+ l = [
555
+ item1,>
556
+ |
557
+ ```
558
+ """
559
+ lines = code.split('\n')
560
+ if not lines:
561
+ return 0
562
+ bracket_line_idx, bracket_col_idx = _line_col_from_idx(code, char_idx)
563
+ # Get the line where the last unclosed parenthesis is
564
+ bracket_line = lines[bracket_line_idx]
565
+ bracket_line_indent = get_leading_spaces(bracket_line)
566
+
567
+ # Check text after bracket on same line
568
+ post_bracket_text_idx = None
569
+ for i in range(bracket_col_idx + 1, len(bracket_line)):
570
+ if bracket_line[i] not in (' ', '\t'):
571
+ post_bracket_text_idx = i
572
+ break
573
+
574
+ if post_bracket_text_idx is not None:
575
+ # Align to the first non-whitespace character after bracket
576
+ return post_bracket_text_idx
577
+ else:
578
+ # No text after bracket on the same line
579
+ # find next non-empty line
580
+ next_non_empty_line_idx = None
581
+ for i in range(bracket_line_idx + 1, len(lines)):
582
+ if lines[i].strip():
583
+ next_non_empty_line_idx = i
584
+ break
585
+
586
+ if next_non_empty_line_idx is None:
587
+ # no subsequent non-empty lines
588
+ return bracket_col_idx + 1
589
+ else:
590
+ # align to the first non-whitespace character of the next line
591
+ next_line = lines[next_non_empty_line_idx]
592
+ for i, ch in enumerate(next_line):
593
+ if ch not in (' ', '\t'):
594
+ return i
595
+ return bracket_col_idx + 1
596
+
597
+
598
+ def _indent_after_list_tuple_set_or_dict(code: str, bracket_stack, bracket_closings) -> int:
599
+ """
600
+ Return after closing of a single-line list should not change indentation.
601
+
602
+ ```
603
+ l = [element1, element2]>
604
+ |
605
+ ```
606
+
607
+ Return after closing of a single-line tuple should not change indentation.
608
+
609
+ ```
610
+ t = [element1, element2]>
611
+ |
612
+ ```
613
+
614
+ Return after closing of a single-line set should not change indentation.
615
+
616
+ ```
617
+ s = {element1, element2}>
618
+ |
619
+ ```
620
+
621
+ Return after closing of a single-line list should not change indentation.
622
+
623
+ ```
624
+ d = {key1: val1, key2: val2}>
625
+ |
626
+ ```
627
+
628
+ Return after closing of list should trigger dedent to scope level, regardless of the indentation of the current line
629
+
630
+ ```
631
+ l = [element1,
632
+ element2]>
633
+ |
634
+ ```
635
+
636
+ Return after closing of tuple should trigger dedent to scope level, regardless of the indentation of the current line
637
+
638
+ ```
639
+ t = (element1,
640
+ element2)>
641
+ |
642
+ ```
643
+
644
+ Return after closing of set should trigger dedent to scope level, regardless of the indentation of the current line
645
+
646
+ ```
647
+ s = {element1,
648
+ element2}>
649
+ |
650
+ ```
651
+
652
+ Return after closing of dict should trigger dedent to scope level, regardless of the indentation of the current line
653
+
654
+ ```
655
+ d = {key1: val1,
656
+ key2: val2}>
657
+ |
658
+ ```
659
+
660
+ Return after closing of dict should als work with trailing spaces
661
+
662
+ ```
663
+ d = {key1: val1,
664
+ key2: val2} >
665
+ |
666
+ ```
667
+ """
668
+ lines = code.split('\n')
669
+ if not lines:
670
+ return 0
671
+
672
+ last_line_idx = len(lines) - 1
673
+ last_line = lines[last_line_idx]
674
+ last_line_indent = get_leading_spaces(last_line)
675
+ if last_line and last_line.rstrip()[-1] in (')', ']', '}'):
676
+ # We closed a bracket
677
+ if last_line_idx in bracket_closings:
678
+ open_line_idx, open_line_indent, open_bracket = bracket_closings[last_line_idx]
679
+ if open_line_idx == last_line_idx:
680
+ # single-line bracket => no change
681
+ return last_line_indent
682
+ else:
683
+ # multi-line => dedent to open bracket line's indent
684
+ return open_line_indent
685
+ else:
686
+ # bracket not found or mismatched => no change
687
+ return last_line_indent
688
+ else:
689
+ # no bracket close => no change
690
+ return last_line_indent
691
+
692
+
693
+ def python_auto_indent(code: str) -> int:
694
+ """
695
+ As fallback, should preserve indentation of previous line.
696
+
697
+ ```
698
+ def test():
699
+ pass>
700
+ |
701
+ ```
702
+
703
+ As fallback, should preserve indentation of previous line.
704
+
705
+ ```
706
+ def test():
707
+ pass
708
+ >
709
+ |
710
+ ```
711
+
712
+ Should dedent after return
713
+
714
+ ```
715
+ def test():
716
+ return>
717
+ |
718
+ ```
719
+
720
+ Should not be affected by irrelevant whitespace
721
+
722
+ ```
723
+ # 1) list item
724
+ x = 1>
725
+ |
726
+ ```
727
+
728
+ Should not be affected by irrelevant whitespace
729
+
730
+ ```
731
+ # (1 list item
732
+ x = 1>
733
+ |
734
+ ```
735
+ """
736
+ lines = code.splitlines()
737
+ # If code ends with a newline => cursor is on a fresh line
738
+ if code.endswith('\n'):
739
+ lines.append('')
740
+ if not lines:
741
+ return 0
742
+ last_line = lines[-1].rstrip()
743
+
744
+ # 1. ends with colon => block opener
745
+ if last_line.endswith(":"):
746
+ logging.info('indent after block opener')
747
+ return _indent_after_block_opener(code)
748
+
749
+ # 2. unmatched '(' => function call/def or tuple
750
+ last_paren_idx_tuple = _get_unclosed_open_idx(code, '(', ')')
751
+ if last_paren_idx_tuple:
752
+ preceding_idx = last_paren_idx_tuple - 1
753
+ while preceding_idx >= 0 and code[preceding_idx].isspace():
754
+ preceding_idx -= 1
755
+ if preceding_idx >= 0:
756
+ preceding_char = code[preceding_idx]
757
+ # If preceding char is alnum or _, treat as function call/def
758
+ if preceding_char.isalnum() or preceding_char == "_":
759
+ logging.info("indent as unclosed call/def")
760
+ return _indent_inside_unclosed_call_def_or_class(
761
+ code, last_paren_idx_tuple)
762
+ else:
763
+ logging.info("indent as unclosed tuple")
764
+ return _indent_inside_uncloded_list_tuple_set_or_dict(
765
+ code, last_paren_idx_tuple)
766
+
767
+ # 3. unmatched '[' or '{'
768
+ last_paren_idx_list = _get_unclosed_open_idx(code, '[', ']')
769
+ last_paren_idx_dict = _get_unclosed_open_idx(code, '{', '}')
770
+ char_idx = max(last_paren_idx_list, last_paren_idx_dict)
771
+ if char_idx:
772
+ logging.info("indent as unclosed list, dict, or set")
773
+ return _indent_inside_uncloded_list_tuple_set_or_dict(code, char_idx)
774
+
775
+ # 4. just closed a bracket => after bracket
776
+ bracket_stack, bracket_closings = parse_brackets(code)
777
+ if last_line.endswith(("]", "}", ")")):
778
+ logging.info('indent as after iterable')
779
+ return _indent_after_list_tuple_set_or_dict(
780
+ code, bracket_stack, bracket_closings)
781
+
782
+ # 5. Check if the current line is a dedent keyword
783
+ current_line_full = lines[-1]
784
+ current_line_strip = current_line_full.strip()
785
+ leading_spaces = get_leading_spaces(current_line_full)
786
+ if current_line_strip in ('return', 'break', 'continue'):
787
+ return max(0, leading_spaces - settings.tab_width)
788
+
789
+ # 6. Check if the current line starts with def or class. If so then we need
790
+ # to indent. This is a fallback mechanism to catch situations in which this
791
+ # was not properly caught by the above logic.
792
+ if current_line_strip.startswith('def') or current_line_strip .startswith('class'):
793
+ logging.info('fallback indent as def or class')
794
+ return leading_spaces + settings.tab_width
795
+
796
+ # 7. fallback => preserve current line indent
797
+ logging.info('indent fallback to current line')
798
+ return leading_spaces
799
+
800
+
801
+ def extract_cells_from_code(code: str) -> list:
802
+ """Extracts cells from code. The return value is a
803
+ list of cells, where each cell is a dict like so:
804
+
805
+ {
806
+ 'description': str | None,
807
+ 'start_pos': int,
808
+ 'end_pos': int,
809
+ 'code': str
810
+ }
811
+
812
+ A description only applies to cells that have been defined with
813
+ triple-quoted strings. Otherwise it is None.
814
+
815
+ Cells can be defined by:
816
+ - # %%
817
+ - #%%
818
+ - # In[]:
819
+ - Triple-quoted strings
820
+ """
821
+ # Store all detected cells
822
+ cells = []
823
+
824
+ # Track the current position
825
+ current_pos = 0
826
+
827
+ # Define regex patterns for cell markers
828
+ marker_pattern = re.compile(r'^(# %%|#%%|# In\[\]:?).*?$', re.MULTILINE)
829
+ triple_quotes_pattern = re.compile(r'(""".*?"""|\'\'\'.*?\'\'\')', re.DOTALL)
830
+
831
+ # Find all marker-based cells
832
+ marker_matches = list(marker_pattern.finditer(code))
833
+
834
+ # Find all triple-quoted docstrings that are at the top level
835
+ # (we'll filter out non-top-level ones later)
836
+ docstring_matches = list(triple_quotes_pattern.finditer(code))
837
+
838
+ # Combine all match types and sort by position
839
+ all_markers = []
840
+
841
+ # Add marker matches
842
+ for match in marker_matches:
843
+ start_pos = match.start()
844
+ all_markers.append({
845
+ 'type': 'marker',
846
+ 'match': match,
847
+ 'start_pos': start_pos
848
+ })
849
+
850
+ # Add docstring matches
851
+ for match in docstring_matches:
852
+ start_pos = match.start()
853
+ # Only include if not inside string/comment (simplified check)
854
+ # We'll do more thorough validation later
855
+ all_markers.append({
856
+ 'type': 'docstring',
857
+ 'match': match,
858
+ 'start_pos': start_pos
859
+ })
860
+
861
+ # Sort all markers by position
862
+ all_markers.sort(key=lambda x: x['start_pos'])
863
+
864
+ # Process each marker to create cells
865
+ for i, marker in enumerate(all_markers):
866
+ match = marker['match']
867
+ start_pos = marker['start_pos']
868
+
869
+ # Determine where this cell ends (next marker or end of file)
870
+ if i < len(all_markers) - 1:
871
+ end_pos = all_markers[i + 1]['start_pos']
872
+ else:
873
+ end_pos = len(code)
874
+
875
+ # Check if this docstring is actually a top-level triple-quoted string
876
+ # and not part of a function definition, string literal, etc.
877
+ if marker['type'] == 'docstring':
878
+ # Skip if not a top-level docstring (simplified check)
879
+ # This is a simplification; a more robust solution would check code structure
880
+ line_start = code.rfind('\n', 0, start_pos) + 1
881
+ if line_start < start_pos:
882
+ indent = code[line_start:start_pos].strip()
883
+ if indent: # If there's text before the docstring on the same line, skip it
884
+ continue
885
+
886
+ # Extract description from the docstring
887
+ docstring_content = match.group(0)[3:-3].strip()
888
+
889
+ # Extract code content (from end of docstring to end of cell)
890
+ docstring_end = match.end()
891
+ cell_code = code[docstring_end:end_pos].strip()
892
+
893
+ cells.append({
894
+ 'description': docstring_content,
895
+ 'start_pos': start_pos,
896
+ 'end_pos': end_pos,
897
+ 'code': cell_code
898
+ })
899
+
900
+ else: # Marker-based cell
901
+ # Extract code content (from end of marker line to end of cell)
902
+ marker_line_end = code.find('\n', start_pos)
903
+ if marker_line_end == -1: # If no newline is found
904
+ marker_line_end = len(code)
905
+
906
+ cell_code = code[marker_line_end + 1:end_pos].strip()
907
+
908
+ cells.append({
909
+ 'description': None,
910
+ 'start_pos': start_pos,
911
+ 'end_pos': end_pos,
912
+ 'code': cell_code
913
+ })
914
+
915
+ # Handle the case where the file starts with code before any marker
916
+ if all_markers and all_markers[0]['start_pos'] > 0:
917
+ first_code = code[:all_markers[0]['start_pos']].strip()
918
+ if first_code:
919
+ cells.insert(0, {
920
+ 'description': None,
921
+ 'start_pos': 0,
922
+ 'end_pos': all_markers[0]['start_pos'],
923
+ 'code': first_code
924
+ })
925
+
926
+ # Handle the case where there are no markers at all
927
+ if not cells and code.strip():
928
+ cells.append({
929
+ 'description': None,
930
+ 'start_pos': 0,
931
+ 'end_pos': len(code),
932
+ 'code': code.strip()
933
+ })
934
+
935
+ return cells
936
+