ssc_codegen 0.32.3__tar.gz → 0.34.0__tar.gz

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 (88) hide show
  1. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/PKG-INFO +11 -8
  2. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/README.md +10 -7
  3. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/pyproject.toml +1 -1
  4. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/__init__.py +4 -0
  5. ssc_codegen-0.34.0/ssc_codegen/ast/function.py +28 -0
  6. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/types.py +1 -0
  7. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/expressions.py +70 -37
  8. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/linter.py +73 -6
  9. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/module_handler.py +31 -1
  10. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/reader.py +6 -0
  11. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/struct_parser.py +124 -5
  12. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/health.py +16 -0
  13. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/visitor.py +81 -14
  14. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/javascript/visitor.py +63 -8
  15. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/rest.py +2 -2
  16. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/visitor.py +67 -13
  17. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/traversal/utils.py +21 -0
  18. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/traversal/walker.py +4 -0
  19. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/.gitignore +0 -0
  20. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/LICENSE +0 -0
  21. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/__init__.py +0 -0
  22. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/_logging.py +0 -0
  23. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/array.py +0 -0
  24. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/base.py +0 -0
  25. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/cast.py +0 -0
  26. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/control.py +0 -0
  27. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/extract.py +0 -0
  28. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/helpers.py +0 -0
  29. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/jsondef.py +0 -0
  30. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/module.py +0 -0
  31. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/predicate_containers.py +0 -0
  32. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/predicate_ops.py +0 -0
  33. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/regex.py +0 -0
  34. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/rest.py +0 -0
  35. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/selectors.py +0 -0
  36. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/string.py +0 -0
  37. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/struct.py +0 -0
  38. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/ast/typedef.py +0 -0
  39. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/__init__.py +0 -0
  40. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/contexts.py +0 -0
  41. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/format.py +0 -0
  42. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/predicates.py +0 -0
  43. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/rest_artifacts.py +0 -0
  44. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/core/type_checking.py +0 -0
  45. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/exceptions.py +0 -0
  46. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/explore.py +0 -0
  47. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/generation/__init__.py +0 -0
  48. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/generation/builder.py +0 -0
  49. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/generation/runtime.py +0 -0
  50. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/main.py +0 -0
  51. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/naming.py +0 -0
  52. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/parsers/__init__.py +0 -0
  53. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/parsers/curl.py +0 -0
  54. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/parsers/http.py +0 -0
  55. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/regex_utils.py +0 -0
  56. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/request_spec.py +0 -0
  57. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/__init__.py +0 -0
  58. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/__init__.py +0 -0
  59. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/http_libs/__init__.py +0 -0
  60. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/http_libs/base.py +0 -0
  61. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/http_libs/nethttp.py +0 -0
  62. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/literals.py +0 -0
  63. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/regex.py +0 -0
  64. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/rest.py +0 -0
  65. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/golang/runtime.py +0 -0
  66. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/javascript/__init__.py +0 -0
  67. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/javascript/http_libs/__init__.py +0 -0
  68. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/javascript/http_libs/axios.py +0 -0
  69. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/javascript/http_libs/base.py +0 -0
  70. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/javascript/http_libs/fetch.py +0 -0
  71. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/javascript/rest.py +0 -0
  72. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/profile.py +0 -0
  73. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/__init__.py +0 -0
  74. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/html_libs/__init__.py +0 -0
  75. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/html_libs/base.py +0 -0
  76. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/html_libs/bs4.py +0 -0
  77. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/html_libs/lxml.py +0 -0
  78. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/html_libs/parsel.py +0 -0
  79. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/html_libs/slax.py +0 -0
  80. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/http_libs/__init__.py +0 -0
  81. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/http_libs/aiohttp.py +0 -0
  82. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/http_libs/base.py +0 -0
  83. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/http_libs/httpx.py +0 -0
  84. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/python/http_libs/requests.py +0 -0
  85. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/resolver.py +0 -0
  86. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/targets/spec.py +0 -0
  87. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/traversal/__init__.py +0 -0
  88. {ssc_codegen-0.32.3 → ssc_codegen-0.34.0}/ssc_codegen/traversal/context.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ssc_codegen
3
- Version: 0.32.3
3
+ Version: 0.34.0
4
4
  Summary: Python-dsl code converter to html parser for web scraping
5
5
  Project-URL: Documentation, https://github.com/vypivshiy/selector_schema_codegen#readme
6
6
  Project-URL: Issues, https://github.com/vypivshiy/selector_schema_codegen/issues
@@ -69,7 +69,7 @@ uv tool install ssc_codegen
69
69
  Generate Python parser:
70
70
 
71
71
  ```bash
72
- ssc-gen generate books.kdl -l python -L bs4 -o ./output
72
+ ssc-gen generate python books.kdl -L bs4 -o ./output
73
73
  ```
74
74
 
75
75
  ## Usage
@@ -78,22 +78,25 @@ ssc-gen generate books.kdl -l python -L bs4 -o ./output
78
78
 
79
79
  ```bash
80
80
  # single file (Python + bs4)
81
- ssc-gen generate schema.kdl -l python -L bs4 -o ./output
81
+ ssc-gen generate python schema.kdl -L bs4 -o ./output
82
82
 
83
83
  # all .kdl files in a directory (JavaScript)
84
- ssc-gen generate examples/ -l js -o ./output
84
+ ssc-gen generate js examples/ -o ./output
85
+
86
+ # Go (goquery + net/http)
87
+ ssc-gen generate go schema.kdl -o ./output
85
88
 
86
89
  # with custom package name
87
- ssc-gen generate schema.kdl -l python -L bs4 -o ./parsers --package scraper
90
+ ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
88
91
 
89
92
  # with @request support (REST/HTTP codegen)
90
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out --http-client httpx
93
+ ssc-gen generate python schema.kdl -L bs4 -o ./out --http-client httpx
91
94
 
92
95
  # extract helper functions into a separate runtime module
93
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out -R
96
+ ssc-gen generate python schema.kdl -L bs4 -o ./out -R
94
97
  ```
95
98
 
96
- Languages (`--lang / -l`): `python`, `js`.
99
+ Languages: `generate python`, `generate js`, `generate go`.
97
100
  HTML libraries (`--lib / -L`, Python only): `bs4` (default), `lxml`, `parsel`, `slax`.
98
101
 
99
102
  ### Lint schemas
@@ -37,7 +37,7 @@ uv tool install ssc_codegen
37
37
  Generate Python parser:
38
38
 
39
39
  ```bash
40
- ssc-gen generate books.kdl -l python -L bs4 -o ./output
40
+ ssc-gen generate python books.kdl -L bs4 -o ./output
41
41
  ```
42
42
 
43
43
  ## Usage
@@ -46,22 +46,25 @@ ssc-gen generate books.kdl -l python -L bs4 -o ./output
46
46
 
47
47
  ```bash
48
48
  # single file (Python + bs4)
49
- ssc-gen generate schema.kdl -l python -L bs4 -o ./output
49
+ ssc-gen generate python schema.kdl -L bs4 -o ./output
50
50
 
51
51
  # all .kdl files in a directory (JavaScript)
52
- ssc-gen generate examples/ -l js -o ./output
52
+ ssc-gen generate js examples/ -o ./output
53
+
54
+ # Go (goquery + net/http)
55
+ ssc-gen generate go schema.kdl -o ./output
53
56
 
54
57
  # with custom package name
55
- ssc-gen generate schema.kdl -l python -L bs4 -o ./parsers --package scraper
58
+ ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
56
59
 
57
60
  # with @request support (REST/HTTP codegen)
58
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out --http-client httpx
61
+ ssc-gen generate python schema.kdl -L bs4 -o ./out --http-client httpx
59
62
 
60
63
  # extract helper functions into a separate runtime module
61
- ssc-gen generate schema.kdl -l python -L bs4 -o ./out -R
64
+ ssc-gen generate python schema.kdl -L bs4 -o ./out -R
62
65
  ```
63
66
 
64
- Languages (`--lang / -l`): `python`, `js`.
67
+ Languages: `generate python`, `generate js`, `generate go`.
65
68
  HTML libraries (`--lib / -L`, Python only): `bs4` (default), `lxml`, `parsel`, `slax`.
66
69
 
67
70
  ### Lint schemas
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.32.3"
3
+ version = "0.34.0"
4
4
  description = "Python-dsl code converter to html parser for web scraping "
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -20,6 +20,8 @@ from .module import (
20
20
  Utilities,
21
21
  )
22
22
 
23
+ from .function import FunctionDef
24
+
23
25
  from .typedef import TypeDef, TypeDefField
24
26
 
25
27
  from .jsondef import JsonDef, JsonDefField
@@ -147,6 +149,8 @@ __all__ = [
147
149
  "CodeEndHook",
148
150
  "Docstring",
149
151
  "Utilities",
152
+ # function
153
+ "FunctionDef",
150
154
  # typedef
151
155
  "TypeDef",
152
156
  "TypeDefField",
@@ -0,0 +1,28 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from .base import Node
6
+ from .types import TypeInfo, VariableType
7
+
8
+
9
+ @dataclass
10
+ class FunctionDef(Node):
11
+ """Module-level single-value extraction function.
12
+
13
+ DSL: ``fn <name> { pipeline }`` or ``(raw)fn <name> { pipeline }``.
14
+
15
+ Generates a standalone function instead of a class.
16
+ ``is_raw`` selects the document type: HTML (DOCUMENT) or plain text (STRING).
17
+ ``ret_type_info`` is resolved after pipeline build (AUTO → concrete type).
18
+ """
19
+
20
+ name: str = ""
21
+ is_raw: bool = False
22
+ doc: str = ""
23
+ accept_type_info: TypeInfo = field(
24
+ default_factory=lambda: TypeInfo(base=VariableType.DOCUMENT)
25
+ )
26
+ ret_type_info: TypeInfo = field(
27
+ default_factory=lambda: TypeInfo(base=VariableType.AUTO)
28
+ )
@@ -49,3 +49,4 @@ class StructType(IntEnum):
49
49
  TABLE = auto()
50
50
  FLAT = auto()
51
51
  REST = auto()
52
+ RAW = auto()
@@ -5,6 +5,7 @@ from __future__ import annotations
5
5
  import ast as _py_ast
6
6
  import re as _re
7
7
  from collections.abc import Sequence
8
+ from types import SimpleNamespace
8
9
  from typing import Any, Callable, TypeAlias
9
10
 
10
11
  from ssc_codegen.ast import (
@@ -18,6 +19,7 @@ from ssc_codegen.ast import (
18
19
  Field,
19
20
  Filter,
20
21
  Fmt,
22
+ FunctionDef,
21
23
  Index,
22
24
  InitField,
23
25
  JsonDef,
@@ -103,6 +105,7 @@ FieldLikeNode: TypeAlias = (
103
105
  | Value
104
106
  | Field
105
107
  | InitField
108
+ | FunctionDef
106
109
  )
107
110
 
108
111
 
@@ -206,7 +209,7 @@ def resolve_index_types(
206
209
  the element base with is_array=False.
207
210
  """
208
211
  if parent.body:
209
- prev = parent.body[-1]
212
+ prev = _prev_type_info(parent)
210
213
  ret_ti = TypeInfo(base=prev.ret_type_info.base)
211
214
  return prev.ret_type_info, ret_ti, prev.is_array
212
215
  auto = TypeInfo(base=VariableType.AUTO)
@@ -420,18 +423,26 @@ def parse_expressions(
420
423
  raise BuildTimeError(f"Unknown expression: {node.name}")
421
424
  lint.push(node.name)
422
425
  lint_pipeline_op(node, lint)
423
- # Guard: ops in _REQUIRES_PREV access parent.body[-1] unconditionally
424
- # in their handlers. Emit a diagnostic and skip the handler when the
425
- # pipeline is empty so far, instead of crashing with IndexError.
426
+ # Guard: ops in _REQUIRES_PREV access parent.body[-1] in their
427
+ # handlers. Emit a diagnostic and skip when the pipeline is empty —
428
+ # UNLESS the parent starts with STRING (RAW struct fields), where
429
+ # string ops are valid as the first operation.
426
430
  if not parent.body and node.name in _REQUIRES_PREV:
427
- lint.error(
428
- node,
429
- message=(f"'{node.name}' requires a preceding operation"),
430
- code="E100",
431
- hint="add a producer first, e.g. `css '.x'; text`",
432
- )
433
- lint.pop()
434
- continue
431
+ parent_accept = getattr(parent, "accept_type_info", None)
432
+ if (
433
+ parent_accept is not None
434
+ and parent_accept.base == VariableType.STRING
435
+ ):
436
+ pass # RAW struct field — string ops OK as first node
437
+ else:
438
+ lint.error(
439
+ node,
440
+ message=(f"'{node.name}' requires a preceding operation"),
441
+ code="E100",
442
+ hint="add a producer first, e.g. `css '.x'; text`",
443
+ )
444
+ lint.pop()
445
+ continue
435
446
  # Guard: handlers reading node.args[i] without a bounds check would
436
447
  # crash with `tuple index out of range` when the user omits args.
437
448
  required_args = _MIN_ARGS.get(node.name)
@@ -471,8 +482,8 @@ def parse_expressions(
471
482
  )
472
483
  parent.ret_type_info = last_ti
473
484
 
474
- # Populate type_info on Field/Value/InitField
475
- if isinstance(parent, (Field, Value, InitField)):
485
+ # Populate type_info on Field/Value/InitField/FunctionDef
486
+ if isinstance(parent, (Field, Value, InitField, FunctionDef)):
476
487
  last_ret = last_ti.base
477
488
  is_arr = (
478
489
  parent.body[-2].is_array if len(parent.body) >= 2 else False
@@ -504,6 +515,28 @@ _ExpressionHandler = Callable[
504
515
  _EXPRESSION_HANDLERS: dict[str, _ExpressionHandler] = {}
505
516
 
506
517
 
518
+ def _prev_type_info(
519
+ parent: FieldLikeNode,
520
+ ) -> SimpleNamespace:
521
+ """Return a namespace with ``ret_type_info`` and ``is_array`` for the
522
+ pipeline cursor.
523
+
524
+ When the pipeline is non-empty, uses the last node's ret type.
525
+ When empty (first op), falls back to the parent's accept type —
526
+ this lets RAW struct fields start directly with string ops
527
+ (re/split/fmt/etc.) since their document is already a STRING.
528
+ """
529
+ if parent.body:
530
+ prev = parent.body[-1]
531
+ return SimpleNamespace(
532
+ ret_type_info=prev.ret_type_info, is_array=prev.is_array
533
+ )
534
+ return SimpleNamespace(
535
+ ret_type_info=parent.accept_type_info,
536
+ is_array=parent.accept_type_info.is_array,
537
+ )
538
+
539
+
507
540
  # Ops whose handlers access ``parent.body[-1]`` unconditionally and therefore
508
541
  # require a preceding node. Used by ``parse_expressions`` to emit a clean
509
542
  # E100 diagnostic instead of crashing with IndexError on an empty pipeline.
@@ -659,7 +692,7 @@ def _expr_text(
659
692
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
660
693
  ):
661
694
  if parent.body:
662
- prev = parent.body[-1]
695
+ prev = _prev_type_info(parent)
663
696
  accept_ti = prev.ret_type_info
664
697
  is_arr = prev.is_array
665
698
  else:
@@ -678,7 +711,7 @@ def _expr_raw(
678
711
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
679
712
  ):
680
713
  if parent.body:
681
- prev = parent.body[-1]
714
+ prev = _prev_type_info(parent)
682
715
  accept_ti = prev.ret_type_info
683
716
  is_arr = prev.is_array
684
717
  else:
@@ -697,7 +730,7 @@ def _expr_attr(
697
730
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
698
731
  ):
699
732
  if parent.body:
700
- prev = parent.body[-1]
733
+ prev = _prev_type_info(parent)
701
734
  accept_ti = prev.ret_type_info
702
735
  is_arr = prev.is_array
703
736
  else:
@@ -727,7 +760,7 @@ def _expr_trim(
727
760
  if node.args
728
761
  else ""
729
762
  )
730
- prev = parent.body[-1]
763
+ prev = _prev_type_info(parent)
731
764
  return Trim(
732
765
  parent=parent,
733
766
  accept_type_info=prev.ret_type_info,
@@ -749,7 +782,7 @@ def _expr_ltrim(
749
782
  if node.args
750
783
  else ""
751
784
  )
752
- prev = parent.body[-1]
785
+ prev = _prev_type_info(parent)
753
786
  return Ltrim(
754
787
  parent=parent,
755
788
  accept_type_info=prev.ret_type_info,
@@ -771,7 +804,7 @@ def _expr_rtrim(
771
804
  if node.args
772
805
  else ""
773
806
  )
774
- prev = parent.body[-1]
807
+ prev = _prev_type_info(parent)
775
808
  return Rtrim(
776
809
  parent=parent,
777
810
  accept_type_info=prev.ret_type_info,
@@ -785,7 +818,7 @@ def _expr_rtrim(
785
818
  def _expr_norm_space(
786
819
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
787
820
  ):
788
- prev = parent.body[-1]
821
+ prev = _prev_type_info(parent)
789
822
  return NormalizeSpace(
790
823
  parent=parent,
791
824
  accept_type_info=prev.ret_type_info,
@@ -799,7 +832,7 @@ def _expr_rm_prefix(
799
832
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
800
833
  ):
801
834
  substr = ctx.property_defines.get(node.args[0].value, node.args[0].value)
802
- prev = parent.body[-1]
835
+ prev = _prev_type_info(parent)
803
836
  return RmPrefix(
804
837
  parent=parent,
805
838
  accept_type_info=prev.ret_type_info,
@@ -814,7 +847,7 @@ def _expr_rm_suffix(
814
847
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
815
848
  ):
816
849
  substr = ctx.property_defines.get(node.args[0].value, node.args[0].value)
817
- prev = parent.body[-1]
850
+ prev = _prev_type_info(parent)
818
851
  return RmSuffix(
819
852
  parent=parent,
820
853
  accept_type_info=prev.ret_type_info,
@@ -829,7 +862,7 @@ def _expr_rm_prefix_suffix(
829
862
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
830
863
  ):
831
864
  substr = ctx.property_defines.get(node.args[0].value, node.args[0].value)
832
- prev = parent.body[-1]
865
+ prev = _prev_type_info(parent)
833
866
  return RmPrefixSuffix(
834
867
  parent=parent,
835
868
  accept_type_info=prev.ret_type_info,
@@ -844,7 +877,7 @@ def _expr_fmt(
844
877
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
845
878
  ):
846
879
  tmpl = ctx.property_defines.get(node.args[0].value, node.args[0].value)
847
- prev = parent.body[-1]
880
+ prev = _prev_type_info(parent)
848
881
  return Fmt(
849
882
  parent=parent,
850
883
  accept_type_info=prev.ret_type_info,
@@ -858,7 +891,7 @@ def _expr_fmt(
858
891
  def _expr_repl(
859
892
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
860
893
  ):
861
- prev = parent.body[-1]
894
+ prev = _prev_type_info(parent)
862
895
  if node.children:
863
896
  items = {
864
897
  str(child.name): str(child.args[0].value) for child in node.children
@@ -890,7 +923,7 @@ def _expr_repl(
890
923
  def _expr_lower(
891
924
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
892
925
  ):
893
- prev = parent.body[-1]
926
+ prev = _prev_type_info(parent)
894
927
  return Lower(
895
928
  parent=parent,
896
929
  accept_type_info=prev.ret_type_info,
@@ -903,7 +936,7 @@ def _expr_lower(
903
936
  def _expr_upper(
904
937
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
905
938
  ):
906
- prev = parent.body[-1]
939
+ prev = _prev_type_info(parent)
907
940
  return Upper(
908
941
  parent=parent,
909
942
  accept_type_info=prev.ret_type_info,
@@ -936,7 +969,7 @@ def _expr_join(
936
969
  def _expr_unescape(
937
970
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
938
971
  ):
939
- prev = parent.body[-1]
972
+ prev = _prev_type_info(parent)
940
973
  return Unescape(
941
974
  parent=parent,
942
975
  accept_type_info=prev.ret_type_info,
@@ -954,7 +987,7 @@ def _expr_re(
954
987
  ):
955
988
  raw = ctx.property_defines.get(node.args[0].value, node.args[0].value)
956
989
  pattern = normalize_regex_pattern(raw)
957
- prev = parent.body[-1]
990
+ prev = _prev_type_info(parent)
958
991
  return Re(
959
992
  parent=parent,
960
993
  pattern=pattern,
@@ -978,7 +1011,7 @@ def _expr_re_all(
978
1011
  def _expr_re_sub(
979
1012
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
980
1013
  ):
981
- prev = parent.body[-1]
1014
+ prev = _prev_type_info(parent)
982
1015
  raw = ctx.property_defines.get(node.args[0].value, node.args[0].value)
983
1016
  pattern = normalize_regex_pattern(raw)
984
1017
  repl = cast(
@@ -1045,7 +1078,7 @@ def _expr_slice(
1045
1078
  ):
1046
1079
  start, end = int(node.args[0].value), int(node.args[1].value)
1047
1080
  if parent.body:
1048
- prev = parent.body[-1]
1081
+ prev = _prev_type_info(parent)
1049
1082
  return Slice(
1050
1083
  parent=parent,
1051
1084
  start=start,
@@ -1079,7 +1112,7 @@ def _expr_unique(
1079
1112
  def _expr_to_int(
1080
1113
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
1081
1114
  ):
1082
- prev = parent.body[-1]
1115
+ prev = _prev_type_info(parent)
1083
1116
  return ToInt(
1084
1117
  parent=parent,
1085
1118
  accept_type_info=prev.ret_type_info,
@@ -1092,7 +1125,7 @@ def _expr_to_int(
1092
1125
  def _expr_to_float(
1093
1126
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
1094
1127
  ):
1095
- prev = parent.body[-1]
1128
+ prev = _prev_type_info(parent)
1096
1129
  return ToFloat(
1097
1130
  parent=parent,
1098
1131
  accept_type_info=prev.ret_type_info,
@@ -1108,7 +1141,7 @@ def _expr_to_bool(
1108
1141
  # Guard against empty parent.body is enforced centrally in
1109
1142
  # parse_expressions via _REQUIRES_PREV — handler is reached only when a
1110
1143
  # preceding node exists.
1111
- prev = parent.body[-1]
1144
+ prev = _prev_type_info(parent)
1112
1145
  return ToBool(parent=parent, accept_type_info=prev.ret_type_info)
1113
1146
 
1114
1147
 
@@ -1164,7 +1197,7 @@ def _expr_fallback(
1164
1197
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
1165
1198
  ):
1166
1199
  value = [] if not node.args else node.args[0].value
1167
- prev = parent.body[-1]
1200
+ prev = _prev_type_info(parent)
1168
1201
  prev_ti = prev.ret_type_info
1169
1202
  fb = Fallback(
1170
1203
  parent=parent,
@@ -1189,7 +1222,7 @@ def _expr_filter(
1189
1222
  ret_type_info=TypeInfo(base=VariableType.DOCUMENT, is_array=True),
1190
1223
  is_array=True,
1191
1224
  )
1192
- prev = parent.body[-1]
1225
+ prev = _prev_type_info(parent)
1193
1226
  return Filter(
1194
1227
  parent=parent,
1195
1228
  accept_type_info=prev.ret_type_info,
@@ -49,7 +49,7 @@ def _node_args(node: KdlNode) -> list[str]:
49
49
 
50
50
 
51
51
  _VALID_STRUCT_TYPES = frozenset(
52
- {"item", "list", "dict", "table", "flat", "rest"}
52
+ {"item", "list", "dict", "table", "flat", "rest", "raw"}
53
53
  )
54
54
 
55
55
  _REQUIRED_RESERVED: dict[str, frozenset[str]] = {
@@ -59,15 +59,18 @@ _REQUIRED_RESERVED: dict[str, frozenset[str]] = {
59
59
  "table": frozenset({"@table", "@rows", "@match", "@value"}),
60
60
  "flat": frozenset(),
61
61
  "rest": frozenset({"@request"}),
62
+ "raw": frozenset(),
62
63
  }
63
64
 
64
65
  _RESERVED_ALLOWED: dict[str, frozenset[str] | None] = {
65
66
  "@request": None,
66
67
  "@doc": None,
67
- "@pre-validate": frozenset({"item", "list", "dict", "table", "flat"}),
68
- "@check": frozenset({"item", "list", "dict", "table", "flat"}),
69
- "@init": frozenset({"item", "list", "dict", "table", "flat"}),
70
- "@split-doc": frozenset({"list", "dict"}),
68
+ "@pre-validate": frozenset(
69
+ {"item", "list", "dict", "table", "flat", "raw"}
70
+ ),
71
+ "@check": frozenset({"item", "list", "dict", "table", "flat", "raw"}),
72
+ "@init": frozenset({"item", "list", "dict", "table", "flat", "raw"}),
73
+ "@split-doc": frozenset({"list", "dict", "raw"}),
71
74
  "@key": frozenset({"dict"}),
72
75
  "@value": frozenset({"dict", "table"}),
73
76
  "@table": frozenset({"table"}),
@@ -165,6 +168,7 @@ def lint_module(
165
168
  _lint_defines(doc, source_path, diags, children_defines)
166
169
  _lint_json_defs(doc, source_path, diags, children_defines)
167
170
  _lint_structs(doc, source_path, diags, children_defines)
171
+ _lint_fns(doc, source_path, diags)
168
172
  _lint_nested_topdown(doc, source_path, diags)
169
173
  _lint_json_refs_topdown(doc, source_path, diags, children_defines)
170
174
  return diags
@@ -175,7 +179,7 @@ def _lint_top_level(
175
179
  source_path: str,
176
180
  diags: list[ReadDiagnostic],
177
181
  ) -> None:
178
- for node in doc.select(":root:not(@doc, json, struct, define, import)"):
182
+ for node in doc.select(":root:not(@doc, json, struct, fn, define, import)"):
179
183
  diags.append(
180
184
  _error(
181
185
  node,
@@ -375,6 +379,69 @@ def _lint_structs(
375
379
  _lint_single_struct(node, source_path, diags, children_defines)
376
380
 
377
381
 
382
+ # Directives allowed inside ``fn`` / ``(raw)fn`` body.
383
+ _FN_ALLOWED_RESERVED: frozenset[str] = frozenset({"@doc"})
384
+
385
+
386
+ def _lint_fns(
387
+ doc: KdlDocument,
388
+ source_path: str,
389
+ diags: list[ReadDiagnostic],
390
+ ) -> None:
391
+ """Structural lint for ``fn`` / ``(raw)fn`` directives."""
392
+ for node in doc.select("fn:root"):
393
+ name = _node_arg(node, 0)
394
+ if not name:
395
+ diags.append(
396
+ _error(
397
+ node,
398
+ "'fn' requires a name",
399
+ source_path,
400
+ code="E001",
401
+ hint='example: fn page_title { css "h1" { text } }',
402
+ )
403
+ )
404
+ continue
405
+
406
+ annotation = node.type_annotation
407
+ if annotation and annotation != "(raw)":
408
+ diags.append(
409
+ _error(
410
+ node,
411
+ f"unsupported fn annotation '{annotation}' — only '(raw)' is recognized",
412
+ source_path,
413
+ code="E400",
414
+ hint="use 'fn { ... }' (HTML) or '(raw)fn { ... }' (plain text)",
415
+ )
416
+ )
417
+
418
+ ops = list(node.children)
419
+ if not ops:
420
+ diags.append(
421
+ _error(
422
+ node,
423
+ f"fn '{name}' must contain at least one pipeline operation",
424
+ source_path,
425
+ code="E001",
426
+ hint='example: fn title { css "h1" { text } }',
427
+ )
428
+ )
429
+
430
+ for child in ops:
431
+ if (
432
+ child.name.startswith("@")
433
+ and child.name not in _FN_ALLOWED_RESERVED
434
+ ):
435
+ diags.append(
436
+ _error(
437
+ child,
438
+ f"'{child.name}' is not allowed inside fn — use a struct for init/check/request/etc.",
439
+ source_path,
440
+ code="E203",
441
+ )
442
+ )
443
+
444
+
378
445
  def _lint_nested_topdown(
379
446
  doc: KdlDocument,
380
447
  source_path: str,
@@ -5,12 +5,15 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from ssc_codegen.ast import (
8
+ FunctionDef,
8
9
  JsonDef,
9
10
  Module,
10
11
  StructBase,
11
12
  Struct,
12
13
  StructRest,
13
14
  StructType,
15
+ VariableType,
16
+ TypeInfo,
14
17
  )
15
18
  from ssc_codegen.exceptions import BuildTimeError
16
19
  from kdlquery import KdlNode, parse as kdl_parse
@@ -23,7 +26,11 @@ from ssc_codegen.core.contexts import (
23
26
  ParseContext,
24
27
  )
25
28
  from ssc_codegen.core.expressions import resolve_define_references
26
- from ssc_codegen.core.struct_parser import parse_json_fields, parse_struct
29
+ from ssc_codegen.core.struct_parser import (
30
+ parse_function,
31
+ parse_json_fields,
32
+ parse_struct,
33
+ )
27
34
 
28
35
  _KDL_TEXT_ENCODING = "utf-8-sig"
29
36
 
@@ -42,6 +49,7 @@ def handle_struct(
42
49
  "dict": StructType.DICT,
43
50
  "table": StructType.TABLE,
44
51
  "rest": StructType.REST,
52
+ "raw": StructType.RAW,
45
53
  }
46
54
  st = struct_type_map.get(type_)
47
55
  if st is None:
@@ -64,6 +72,28 @@ def handle_struct(
64
72
  return struct
65
73
 
66
74
 
75
+ def handle_function(
76
+ node: KdlNode, module: Module, ctx: ParseContext, lint: LintContext
77
+ ) -> FunctionDef:
78
+ raw_annotation = node.type_annotation
79
+ is_raw = raw_annotation == "(raw)"
80
+ if not node.args:
81
+ raise BuildTimeError("'fn' requires a name")
82
+ name = str(node.args[0].value)
83
+ fn = FunctionDef(
84
+ parent=module,
85
+ name=name,
86
+ is_raw=is_raw,
87
+ accept_type_info=(
88
+ TypeInfo(base=VariableType.STRING)
89
+ if is_raw
90
+ else TypeInfo(base=VariableType.DOCUMENT)
91
+ ),
92
+ )
93
+ parse_function(node.children, fn, ctx, lint)
94
+ return fn
95
+
96
+
67
97
  def handle_json(
68
98
  node: KdlNode, module: Module, ctx: ParseContext, lint: LintContext
69
99
  ) -> JsonDef:
@@ -16,6 +16,7 @@ from ssc_codegen.core.expressions import typedef_from_struct
16
16
  from ssc_codegen.core.rest_artifacts import rest_artifacts_from_struct
17
17
  from ssc_codegen.core.module_handler import (
18
18
  handle_define,
19
+ handle_function,
19
20
  handle_json,
20
21
  handle_struct,
21
22
  resolve_imports,
@@ -68,6 +69,7 @@ def parse_module(
68
69
  module.source_file = source_path.name if source_path else ""
69
70
  structs: list = []
70
71
  typedefs: list = []
72
+ functions: list = []
71
73
 
72
74
  for node in top_nodes:
73
75
  lint.push(node.name)
@@ -79,6 +81,9 @@ def parse_module(
79
81
  struct = handle_struct(node, module, ctx, lint)
80
82
  typedefs.append(typedef_from_struct(struct, module))
81
83
  structs.append(struct)
84
+ elif node.name == "fn":
85
+ fn = handle_function(node, module, ctx, lint)
86
+ functions.append(fn)
82
87
  elif node.name in ("define", "import"):
83
88
  pass # already handled
84
89
  else:
@@ -95,6 +100,7 @@ def parse_module(
95
100
  if isinstance(struct, StructRest):
96
101
  module.body.extend(rest_artifacts_from_struct(struct, module))
97
102
  module.body.append(struct)
103
+ module.body.extend(functions)
98
104
 
99
105
  # merge lint diagnostics into walker diagnostics
100
106
  diagnostics.extend(lint.diagnostics)