ssc_codegen 0.37.1__tar.gz → 0.37.2__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.37.1 → ssc_codegen-0.37.2}/PKG-INFO +8 -3
  2. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/README.md +10 -5
  3. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/pyproject.toml +1 -11
  4. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/struct.py +21 -15
  5. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/typedef.py +2 -1
  6. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/contexts.py +32 -22
  7. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/expressions.py +14 -0
  8. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/format.py +29 -5
  9. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/linter.py +374 -14
  10. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/module_handler.py +60 -19
  11. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/reader.py +47 -13
  12. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/struct_parser.py +10 -1
  13. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/main.py +114 -11
  14. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/parsers/curl.py +20 -9
  15. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/parsers/http.py +17 -6
  16. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/request_spec.py +84 -3
  17. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/rest.py +106 -54
  18. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/runtime.py +11 -0
  19. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/visitor.py +68 -9
  20. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/javascript/rest.py +48 -14
  21. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/javascript/visitor.py +2 -1
  22. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/rest.py +14 -49
  23. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/visitor.py +4 -13
  24. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/.gitignore +0 -0
  25. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/LICENSE +0 -0
  26. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/__init__.py +0 -0
  27. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/_logging.py +0 -0
  28. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/__init__.py +0 -0
  29. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/array.py +0 -0
  30. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/base.py +0 -0
  31. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/cast.py +0 -0
  32. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/control.py +0 -0
  33. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/extract.py +0 -0
  34. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/function.py +0 -0
  35. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/helpers.py +0 -0
  36. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/jsondef.py +0 -0
  37. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/module.py +0 -0
  38. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/predicate_containers.py +0 -0
  39. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/predicate_ops.py +0 -0
  40. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/regex.py +0 -0
  41. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/rest.py +0 -0
  42. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/selectors.py +0 -0
  43. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/string.py +0 -0
  44. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/ast/types.py +0 -0
  45. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/__init__.py +0 -0
  46. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/predicates.py +0 -0
  47. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/rest_artifacts.py +0 -0
  48. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/core/type_checking.py +0 -0
  49. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/exceptions.py +0 -0
  50. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/explore.py +0 -0
  51. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/generation/__init__.py +0 -0
  52. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/generation/builder.py +0 -0
  53. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/generation/runtime.py +0 -0
  54. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/health.py +0 -0
  55. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/naming.py +0 -0
  56. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/parsers/__init__.py +0 -0
  57. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/regex_utils.py +0 -0
  58. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/__init__.py +0 -0
  59. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/__init__.py +0 -0
  60. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/http_libs/__init__.py +0 -0
  61. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/http_libs/base.py +0 -0
  62. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/http_libs/nethttp.py +0 -0
  63. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/literals.py +0 -0
  64. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/golang/regex.py +0 -0
  65. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/javascript/__init__.py +0 -0
  66. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/javascript/http_libs/__init__.py +0 -0
  67. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/javascript/http_libs/axios.py +0 -0
  68. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/javascript/http_libs/base.py +0 -0
  69. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/javascript/http_libs/fetch.py +0 -0
  70. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/profile.py +0 -0
  71. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/__init__.py +0 -0
  72. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/html_libs/__init__.py +0 -0
  73. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/html_libs/base.py +0 -0
  74. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/html_libs/bs4.py +0 -0
  75. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/html_libs/lxml.py +0 -0
  76. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/html_libs/parsel.py +0 -0
  77. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/html_libs/slax.py +0 -0
  78. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/http_libs/__init__.py +0 -0
  79. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/http_libs/aiohttp.py +0 -0
  80. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/http_libs/base.py +0 -0
  81. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/http_libs/httpx.py +0 -0
  82. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/python/http_libs/requests.py +0 -0
  83. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/resolver.py +0 -0
  84. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/targets/spec.py +0 -0
  85. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/traversal/__init__.py +0 -0
  86. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/traversal/context.py +0 -0
  87. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/traversal/utils.py +0 -0
  88. {ssc_codegen-0.37.1 → ssc_codegen-0.37.2}/ssc_codegen/traversal/walker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ssc_codegen
3
- Version: 0.37.1
3
+ Version: 0.37.2
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
@@ -86,6 +86,9 @@ ssc-gen generate js examples/ -o ./output
86
86
  # Go (goquery + net/http)
87
87
  ssc-gen generate go schema.kdl -o ./output
88
88
 
89
+ # Go package defaults to main; override explicitly when generating a library
90
+ ssc-gen generate go schema.kdl -o ./output --package scraper
91
+
89
92
  # with custom package name
90
93
  ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
91
94
 
@@ -116,12 +119,14 @@ ssc-gen check examples/
116
119
 
117
120
  ```bash
118
121
  # from file
119
- ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4 -i page.html
122
+ ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4 -i page.html
120
123
 
121
124
  # from stdin
122
- curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4
125
+ curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4
123
126
  ```
124
127
 
128
+ `ssc-gen run` executes generated Python in-process. Run only trusted schema files.
129
+
125
130
  ### Health check (verify selectors match elements)
126
131
 
127
132
  ```bash
@@ -51,8 +51,11 @@ ssc-gen generate python schema.kdl -L bs4 -o ./output
51
51
  # all .kdl files in a directory (JavaScript)
52
52
  ssc-gen generate js examples/ -o ./output
53
53
 
54
- # Go (goquery + net/http)
55
- ssc-gen generate go schema.kdl -o ./output
54
+ # Go (goquery + net/http)
55
+ ssc-gen generate go schema.kdl -o ./output
56
+
57
+ # Go package defaults to main; override explicitly when generating a library
58
+ ssc-gen generate go schema.kdl -o ./output --package scraper
56
59
 
57
60
  # with custom package name
58
61
  ssc-gen generate python schema.kdl -L bs4 -o ./parsers --package scraper
@@ -84,11 +87,13 @@ ssc-gen check examples/
84
87
 
85
88
  ```bash
86
89
  # from file
87
- ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4 -i page.html
90
+ ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4 -i page.html
88
91
 
89
92
  # from stdin
90
- curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -l python -L bs4
91
- ```
93
+ curl https://books.toscrape.com/ | ssc-gen run examples/booksToScrape.kdl:MainCatalogue -L bs4
94
+ ```
95
+
96
+ `ssc-gen run` executes generated Python in-process. Run only trusted schema files.
92
97
 
93
98
  ### Health check (verify selectors match elements)
94
99
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ssc_codegen"
3
- version = "0.37.1"
3
+ version = "0.37.2"
4
4
  description = "Python-dsl code converter to html parser for web scraping "
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -78,10 +78,6 @@ exclude = ["__init__.py"]
78
78
  [tool.mypy]
79
79
  python_version = "3.10"
80
80
  show_error_codes = true
81
- exclude = [
82
- "ssc_codegen/ast" # ignore built-in ast and project ast
83
-
84
- ]
85
81
  [[tool.mypy.overrides]]
86
82
  module="tests.*"
87
83
  disallow_untyped_defs = false
@@ -91,12 +87,6 @@ disallow_untyped_defs = false
91
87
  module = ["colorama", "lxml", "lxml.*"]
92
88
  ignore_missing_imports = true
93
89
 
94
- [[tool.mypy.overrides]]
95
- # Large visitor classes with match statements may need relaxed return checks
96
- module = ["ssc_codegen.targets.python.visitor", "ssc_codegen.targets.javascript.visitor"]
97
- disable_error_code = ["return", "return-value"]
98
-
99
-
100
90
  [tool.coverage.report]
101
91
  exclude_also = [
102
92
  'def __repr__',
@@ -136,10 +136,16 @@ class PlaceholderSpec:
136
136
  def parse_placeholder(match: "_re.Match[str]") -> PlaceholderSpec:
137
137
  return PlaceholderSpec(
138
138
  name=match.group(1),
139
- type_name=match.group(2) or "str",
139
+ type_name=cast(
140
+ Literal["str", "int", "float", "bool"],
141
+ match.group(2) or "str",
142
+ ),
140
143
  is_array=bool(match.group(3)),
141
144
  is_optional=bool(match.group(4)),
142
- style=match.group(5) or None,
145
+ style=cast(
146
+ Literal["repeat", "csv", "bracket", "pipe", "space"] | None,
147
+ match.group(5) or None,
148
+ ),
143
149
  )
144
150
 
145
151
 
@@ -260,7 +266,7 @@ class RequestHttp(Node):
260
266
 
261
267
  Created once at parse time from ``parse_to_http(raw_payload)``;
262
268
  converters read fields directly instead of re-parsing raw_payload.
263
- String fields (url, headers, cookies, params, body) are ``Template``
269
+ String fields (url, headers, cookies, params, payload) are ``Template``
264
270
  instances: tokenized literal/placeholder parts.
265
271
  """
266
272
 
@@ -270,7 +276,7 @@ class RequestHttp(Node):
270
276
  cookies: dict[str, PlaceholderTemplate] = field(default_factory=dict)
271
277
  params: dict[str, PlaceholderTemplate] = field(default_factory=dict)
272
278
  body_kind: str = "empty" # "empty" | "json" | "form" | "raw"
273
- body: PlaceholderTemplate | dict[str, PlaceholderTemplate] | None = None
279
+ payload: PlaceholderTemplate | dict[str, PlaceholderTemplate] | None = None
274
280
 
275
281
  @property
276
282
  def placeholders(self) -> list[PlaceholderSpec]:
@@ -289,10 +295,10 @@ class RequestHttp(Node):
289
295
  yield self.url
290
296
  for d in (self.headers, self.cookies, self.params):
291
297
  yield from d.values()
292
- if isinstance(self.body, PlaceholderTemplate):
293
- yield self.body
294
- elif isinstance(self.body, dict):
295
- yield from self.body.values()
298
+ if isinstance(self.payload, PlaceholderTemplate):
299
+ yield self.payload
300
+ elif isinstance(self.payload, dict):
301
+ yield from self.payload.values()
296
302
 
297
303
  def with_renamed_placeholders(
298
304
  self, transform: Callable[[str], str]
@@ -312,13 +318,13 @@ class RequestHttp(Node):
312
318
  ) -> dict[str, PlaceholderTemplate]:
313
319
  return {k: v.renamed(mapping) for k, v in d.items()}
314
320
 
315
- new_body: (
321
+ new_payload: (
316
322
  PlaceholderTemplate | dict[str, PlaceholderTemplate] | None
317
- ) = self.body
318
- if isinstance(self.body, PlaceholderTemplate):
319
- new_body = self.body.renamed(mapping)
320
- elif isinstance(self.body, dict):
321
- new_body = _rename_dict(self.body)
323
+ ) = self.payload
324
+ if isinstance(self.payload, PlaceholderTemplate):
325
+ new_payload = self.payload.renamed(mapping)
326
+ elif isinstance(self.payload, dict):
327
+ new_payload = _rename_dict(self.payload)
322
328
 
323
329
  return RequestHttp(
324
330
  method=self.method,
@@ -327,7 +333,7 @@ class RequestHttp(Node):
327
333
  cookies=_rename_dict(self.cookies),
328
334
  params=_rename_dict(self.params),
329
335
  body_kind=self.body_kind,
330
- body=new_body,
336
+ payload=new_payload,
331
337
  )
332
338
 
333
339
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
  from dataclasses import dataclass
3
+ from typing import cast
3
4
 
4
5
  from .base import Node
5
6
  from .types import StructType
@@ -29,4 +30,4 @@ class TypeDef(Node):
29
30
 
30
31
  @property
31
32
  def fields(self) -> list[TypeDefField]:
32
- return self.body
33
+ return cast(list[TypeDefField], self.body)
@@ -50,6 +50,7 @@ class ParseContext:
50
50
  structs: dict[str, StructBase] = field(default_factory=dict)
51
51
  json_defs: dict[str, JsonDef] = field(default_factory=dict)
52
52
  source_path: Path | None = None
53
+ node_source_paths: dict[int, Path] = field(default_factory=dict)
53
54
 
54
55
  def all_names(self) -> set[str]:
55
56
  return (
@@ -77,6 +78,7 @@ class LintContext:
77
78
  )
78
79
  _predicate_depth: int = field(default=0)
79
80
  _predicate_context: str = ""
81
+ node_source_paths: dict[int, Path] = field(default_factory=dict)
80
82
 
81
83
  def error(
82
84
  self,
@@ -88,18 +90,22 @@ class LintContext:
88
90
  label: str | None = None,
89
91
  notes: list[str] | None = None,
90
92
  ) -> None:
91
- self.diagnostics.append(
92
- ReadDiagnostic(
93
- message=message,
94
- severity=Severity.ERROR,
95
- span=node.span,
96
- path=self.path,
97
- hint=hint,
98
- code=code,
99
- label=label,
100
- notes=tuple(notes) if notes else (),
101
- )
93
+ scope = self.path
94
+ source_path = self.node_source_paths.get(id(node))
95
+ diagnostic_notes = tuple(notes) if notes else ()
96
+ if source_path is not None and scope:
97
+ diagnostic_notes = (*diagnostic_notes, f"scope: {scope}")
98
+ diagnostic = ReadDiagnostic(
99
+ message=message,
100
+ severity=Severity.ERROR,
101
+ span=node.span,
102
+ path=str(source_path) if source_path is not None else scope,
103
+ hint=hint,
104
+ code=code,
105
+ label=label,
106
+ notes=diagnostic_notes,
102
107
  )
108
+ self.diagnostics.append(diagnostic)
103
109
 
104
110
  def warning(
105
111
  self,
@@ -111,18 +117,22 @@ class LintContext:
111
117
  label: str | None = None,
112
118
  notes: list[str] | None = None,
113
119
  ) -> None:
114
- self.diagnostics.append(
115
- ReadDiagnostic(
116
- message=message,
117
- severity=Severity.WARNING,
118
- span=node.span,
119
- path=self.path,
120
- hint=hint,
121
- code=code,
122
- label=label,
123
- notes=tuple(notes) if notes else (),
124
- )
120
+ scope = self.path
121
+ source_path = self.node_source_paths.get(id(node))
122
+ diagnostic_notes = tuple(notes) if notes else ()
123
+ if source_path is not None and scope:
124
+ diagnostic_notes = (*diagnostic_notes, f"scope: {scope}")
125
+ diagnostic = ReadDiagnostic(
126
+ message=message,
127
+ severity=Severity.WARNING,
128
+ span=node.span,
129
+ path=str(source_path) if source_path is not None else scope,
130
+ hint=hint,
131
+ code=code,
132
+ label=label,
133
+ notes=diagnostic_notes,
125
134
  )
135
+ self.diagnostics.append(diagnostic)
126
136
 
127
137
  @property
128
138
  def path(self) -> str:
@@ -606,6 +606,9 @@ def _reg_expr(name: str):
606
606
  def _expr_css(
607
607
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
608
608
  ):
609
+ if not node.children and not node.args:
610
+ lint.error(node, message="'css' requires a selector", code="E001")
611
+ return None
609
612
  if node.children:
610
613
  queries = [
611
614
  resolve_selector_child_name(c.name, ctx) for c in node.children
@@ -622,6 +625,9 @@ def _expr_css(
622
625
  def _expr_css_all(
623
626
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
624
627
  ):
628
+ if not node.children and not node.args:
629
+ lint.error(node, message="'css-all' requires a selector", code="E001")
630
+ return None
625
631
  if node.children:
626
632
  queries = [
627
633
  resolve_selector_child_name(c.name, ctx) for c in node.children
@@ -638,6 +644,9 @@ def _expr_css_all(
638
644
  def _expr_xpath(
639
645
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
640
646
  ):
647
+ if not node.children and not node.args:
648
+ lint.error(node, message="'xpath' requires an expression", code="E001")
649
+ return None
641
650
  if node.children:
642
651
  queries = [
643
652
  resolve_selector_child_name(c.name, ctx) for c in node.children
@@ -654,6 +663,11 @@ def _expr_xpath(
654
663
  def _expr_xpath_all(
655
664
  node: KdlNode, parent: FieldLikeNode, ctx: ParseContext, lint: LintContext
656
665
  ):
666
+ if not node.children and not node.args:
667
+ lint.error(
668
+ node, message="'xpath-all' requires an expression", code="E001"
669
+ )
670
+ return None
657
671
  if node.children:
658
672
  queries = [
659
673
  resolve_selector_child_name(c.name, ctx) for c in node.children
@@ -41,6 +41,7 @@ def format_diagnostic(
41
41
  context_lines: int = 1,
42
42
  ) -> str:
43
43
  """Format a single diagnostic as a human-readable Rust-style string."""
44
+ filepath = _diagnostic_filepath(d, filepath)
44
45
  src = _resolve_src(src, filepath)
45
46
  renderer = _Renderer(use_color=_resolve_color(use_color))
46
47
  return renderer.render(
@@ -66,12 +67,14 @@ def format_diagnostics(
66
67
  [diagnostic_to_dict(d) for d in diagnostics], indent=2
67
68
  )
68
69
 
69
- src = _resolve_src(src, filepath)
70
70
  renderer = _Renderer(use_color=_resolve_color(use_color))
71
71
 
72
72
  sections = [
73
73
  renderer.render(
74
- d, src=src, filepath=filepath, context_lines=context_lines
74
+ d,
75
+ src=_resolve_src(src, _diagnostic_filepath(d, filepath)),
76
+ filepath=_diagnostic_filepath(d, filepath),
77
+ context_lines=context_lines,
75
78
  )
76
79
  for d in diagnostics
77
80
  ]
@@ -97,6 +100,19 @@ def diagnostic_to_dict(d: ReadDiagnostic) -> dict[str, Any]:
97
100
  }
98
101
 
99
102
 
103
+ def _diagnostic_filepath(
104
+ diagnostic: ReadDiagnostic, fallback: str | Path | None
105
+ ) -> str | Path | None:
106
+ if diagnostic.path:
107
+ try:
108
+ path = Path(diagnostic.path)
109
+ if path.is_file():
110
+ return path
111
+ except OSError:
112
+ pass
113
+ return fallback
114
+
115
+
100
116
  # ── Renderer ──────────────────────────────────────────────────────────────────
101
117
 
102
118
 
@@ -271,7 +287,7 @@ class _Renderer:
271
287
  parts: list[str] = []
272
288
  eq = self._styled("=", color=self._dim)
273
289
 
274
- if d.path:
290
+ if d.path and _diagnostic_filepath(d, None) is None:
275
291
  scope = self._styled("scope:", color=self._cyan, bold=True)
276
292
  parts.append(f"{pad} {eq} {scope} {d.path}")
277
293
 
@@ -280,8 +296,16 @@ class _Renderer:
280
296
  parts.append(f"{pad} {eq} {help_} {d.hint}")
281
297
 
282
298
  for note in d.notes:
283
- note_label = self._styled("note:", color=self._magenta, bold=True)
284
- parts.append(f"{pad} {eq} {note_label} {note}")
299
+ if note.startswith("scope: "):
300
+ scope_label = self._styled(
301
+ "scope:", color=self._cyan, bold=True
302
+ )
303
+ parts.append(f"{pad} {eq} {scope_label} {note[7:]}")
304
+ else:
305
+ note_label = self._styled(
306
+ "note:", color=self._magenta, bold=True
307
+ )
308
+ parts.append(f"{pad} {eq} {note_label} {note}")
285
309
 
286
310
  return parts
287
311