@@ -87,6 +87,13 @@ def purge_exercises(app: Sphinx, env: BuildEnvironment, docname: str) -> None:
8787 for label in remove_labels :
8888 del env .sphinx_exercise_registry [label ]
8989
90+ # Purge node order tracking for this document
91+ if (
92+ hasattr (env , "sphinx_exercise_node_order" )
93+ and docname in env .sphinx_exercise_node_order
94+ ):
95+ del env .sphinx_exercise_node_order [docname ]
96+
9097
9198def merge_exercises (
9299 app : Sphinx , env : BuildEnvironment , docnames : Set [str ], other : BuildEnvironment
@@ -103,6 +110,16 @@ def merge_exercises(
103110 ** other .sphinx_exercise_registry ,
104111 }
105112
113+ # Merge node order tracking
114+ if not hasattr (env , "sphinx_exercise_node_order" ):
115+ env .sphinx_exercise_node_order = {}
116+
117+ if hasattr (other , "sphinx_exercise_node_order" ):
118+ env .sphinx_exercise_node_order = {
119+ ** env .sphinx_exercise_node_order ,
120+ ** other .sphinx_exercise_node_order ,
121+ }
122+
106123
107124def init_numfig (app : Sphinx , config : Config ) -> None :
108125 """Initialize numfig"""
@@ -127,23 +144,133 @@ def copy_asset_files(app: Sphinx, exc: Union[bool, Exception]):
127144 copy_asset (path , str (Path (app .outdir ).joinpath ("_static" ).absolute ()))
128145
129146
147+ def validate_exercise_solution_order (app : Sphinx , env : BuildEnvironment ) -> None :
148+ """
149+ Validate that solutions follow their referenced exercises when
150+ exercise_style='solution_follow_exercise' is set.
151+ """
152+ # Only validate if the config option is set
153+ if app .config .exercise_style != "solution_follow_exercise" :
154+ return
155+
156+ if not hasattr (env , "sphinx_exercise_node_order" ):
157+ return
158+
159+ logger = logging .getLogger (__name__ )
160+
161+ # Process each document
162+ for docname , nodes in env .sphinx_exercise_node_order .items ():
163+ # Build a map of exercise labels to their positions and info
164+ exercise_info = {}
165+ for i , node_info in enumerate (nodes ):
166+ if node_info ["type" ] == "exercise" :
167+ exercise_info [node_info ["label" ]] = {
168+ "position" : i ,
169+ "line" : node_info .get ("line" ),
170+ }
171+
172+ # Check each solution
173+ for i , node_info in enumerate (nodes ):
174+ if node_info ["type" ] == "solution" :
175+ target_label = node_info ["target_label" ]
176+ solution_label = node_info ["label" ]
177+ solution_line = node_info .get ("line" )
178+
179+ if not target_label :
180+ continue
181+
182+ # Check if target exercise exists in this document
183+ if target_label not in exercise_info :
184+ # Exercise is in a different document or doesn't exist
185+ docpath = env .doc2path (docname )
186+ path = str (Path (docpath ).with_suffix ("" ))
187+
188+ # Build location string with line number if available
189+ location = f"{ path } :{ solution_line } " if solution_line else path
190+
191+ logger .warning (
192+ f"[sphinx-exercise] Solution '{ solution_label } ' references exercise '{ target_label } ' "
193+ f"which is not in the same document. When exercise_style='solution_follow_exercise', "
194+ f"solutions should appear in the same document as their exercises." ,
195+ location = location ,
196+ color = "yellow" ,
197+ )
198+ continue
199+
200+ # Check if solution comes after exercise
201+ exercise_data = exercise_info [target_label ]
202+ exercise_pos = exercise_data ["position" ]
203+ exercise_line = exercise_data .get ("line" )
204+
205+ if i <= exercise_pos :
206+ docpath = env .doc2path (docname )
207+ path = str (Path (docpath ).with_suffix ("" ))
208+
209+ # Build more informative message with line numbers
210+ if solution_line and exercise_line :
211+ location = f"{ path } :{ solution_line } "
212+ msg = (
213+ f"[sphinx-exercise] Solution '{ solution_label } ' (line { solution_line } ) does not follow "
214+ f"exercise '{ target_label } ' (line { exercise_line } ). "
215+ f"When exercise_style='solution_follow_exercise', solutions should "
216+ f"appear after their referenced exercises."
217+ )
218+ elif solution_line :
219+ location = f"{ path } :{ solution_line } "
220+ msg = (
221+ f"[sphinx-exercise] Solution '{ solution_label } ' does not follow exercise '{ target_label } '. "
222+ f"When exercise_style='solution_follow_exercise', solutions should "
223+ f"appear after their referenced exercises."
224+ )
225+ else :
226+ location = path
227+ msg = (
228+ f"[sphinx-exercise] Solution '{ solution_label } ' does not follow exercise '{ target_label } '. "
229+ f"When exercise_style='solution_follow_exercise', solutions should "
230+ f"appear after their referenced exercises."
231+ )
232+
233+ logger .warning (msg , location = location , color = "yellow" )
234+
235+
130236def doctree_read (app : Sphinx , document : Node ) -> None :
131237 """
132238 Read the doctree and apply updates to sphinx-exercise nodes
133239 """
134240
135241 domain = cast (StandardDomain , app .env .get_domain ("std" ))
136242
243+ # Initialize node order tracking for this document
244+ if not hasattr (app .env , "sphinx_exercise_node_order" ):
245+ app .env .sphinx_exercise_node_order = {}
246+
247+ docname = app .env .docname
248+ if docname not in app .env .sphinx_exercise_node_order :
249+ app .env .sphinx_exercise_node_order [docname ] = []
250+
137251 # Traverse sphinx-exercise nodes
138252 for node in findall (document ):
139253 if is_extension_node (node ):
140254 name = node .get ("names" , [])[0 ]
141255 label = document .nameids [name ]
142- docname = app .env .docname
143256 section_name = node .attributes .get ("title" )
144257 domain .anonlabels [name ] = docname , label
145258 domain .labels [name ] = docname , label , section_name
146259
260+ # Track node order for validation
261+ node_type = node .get ("type" , "unknown" )
262+ node_label = node .get ("label" , "" )
263+ target_label = node .get ("target_label" , None ) # Only for solution nodes
264+
265+ app .env .sphinx_exercise_node_order [docname ].append (
266+ {
267+ "type" : node_type ,
268+ "label" : node_label ,
269+ "target_label" : target_label ,
270+ "line" : node .line if hasattr (node , "line" ) else None ,
271+ }
272+ )
273+
147274
148275def setup (app : Sphinx ) -> Dict [str , Any ]:
149276 app .add_config_value ("hide_solutions" , False , "env" )
@@ -153,6 +280,7 @@ def setup(app: Sphinx) -> Dict[str, Any]:
153280 app .connect ("env-purge-doc" , purge_exercises ) # event order - 5 per file
154281 app .connect ("doctree-read" , doctree_read ) # event order - 8
155282 app .connect ("env-merge-info" , merge_exercises ) # event order - 9
283+ app .connect ("env-updated" , validate_exercise_solution_order ) # event order - 10
156284 app .connect ("build-finished" , copy_asset_files ) # event order - 16
157285
158286 app .add_node (
0 commit comments