Skip to main content

devtools/actors/
source.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::str::FromStr;
7use std::sync::Arc;
8
9use atomic_refcell::AtomicRefCell;
10use devtools_traits::DevtoolScriptControlMsg;
11use malloc_size_of_derive::MallocSizeOf;
12use serde::{Deserialize, Serialize};
13use serde_json::{Map, Value};
14use servo_base::generic_channel::{GenericSender, channel};
15use servo_base::id::PipelineId;
16use servo_url::ServoUrl;
17
18use crate::StreamId;
19use crate::actor::{Actor, ActorError, ActorRegistry, DowncastableActorArc, new_actor_name};
20use crate::actors::breakpoint::BreakpointRequestLocation;
21use crate::protocol::ClientRequest;
22
23/// A `sourceForm` as used in responses to thread `sources` requests.
24///
25/// For now, we also use this for sources in watcher `resource-available-array` messages,
26/// but in Firefox those have extra fields.
27///
28/// <https://firefox-source-docs.mozilla.org/devtools/backend/protocol.html#loading-script-sources>
29#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
30#[serde(rename_all = "camelCase")]
31pub(crate) struct SourceForm {
32    actor: String,
33    /// URL of the script, or URL of the page for inline scripts.
34    url: String,
35    is_black_boxed: bool,
36    /// `introductionType` in SpiderMonkey `CompileOptionsWrapper`.
37    introduction_type: String,
38}
39
40#[derive(Serialize)]
41pub(crate) struct SourcesReply {
42    pub from: String,
43    pub sources: Vec<SourceForm>,
44}
45
46#[derive(MallocSizeOf)]
47pub(crate) struct SourceManager {
48    source_actor_names: AtomicRefCell<BTreeSet<String>>,
49}
50
51impl SourceManager {
52    pub fn new() -> Self {
53        Self {
54            source_actor_names: AtomicRefCell::new(BTreeSet::default()),
55        }
56    }
57
58    pub fn add_source(&self, actor_name: &str) {
59        self.source_actor_names
60            .borrow_mut()
61            .insert(actor_name.to_owned());
62    }
63
64    pub fn source_forms(&self, registry: &ActorRegistry) -> Vec<SourceForm> {
65        self.source_actor_names
66            .borrow()
67            .iter()
68            .map(|source_name| registry.find::<SourceActor>(source_name).source_form())
69            .collect()
70    }
71
72    pub fn find_source(
73        &self,
74        registry: &ActorRegistry,
75        source_url: &str,
76    ) -> Option<DowncastableActorArc<SourceActor>> {
77        for source_name in self.source_actor_names.borrow().iter() {
78            let source_actor = registry.find::<SourceActor>(source_name);
79            if source_actor.url == ServoUrl::from_str(source_url).ok()? {
80                return Some(source_actor);
81            }
82        }
83        None
84    }
85}
86
87#[derive(Clone, Debug, MallocSizeOf)]
88pub(crate) struct SourceActor {
89    /// Actor name.
90    name: String,
91
92    /// URL of the script, or URL of the page for inline scripts.
93    url: ServoUrl,
94
95    /// The ‘black-boxed’ flag, which tells the debugger to avoid pausing inside this script.
96    /// <https://firefox-source-docs.mozilla.org/devtools/backend/protocol.html#black-boxing-sources>
97    is_black_boxed: bool,
98
99    pub content: AtomicRefCell<Option<String>>,
100    content_type: Option<String>,
101
102    pub spidermonkey_id: u32,
103    /// `introductionType` in SpiderMonkey `CompileOptionsWrapper`.
104    introduction_type: String,
105
106    pub script_sender: GenericSender<DevtoolScriptControlMsg>,
107}
108
109#[derive(Serialize)]
110struct SourceContentReply {
111    from: String,
112    #[serde(rename = "contentType")]
113    content_type: Option<String>,
114    source: String,
115}
116
117#[derive(Serialize)]
118struct GetBreakableLinesReply {
119    from: String,
120    // Line numbers are one-based.
121    // <https://firefox-source-docs.mozilla.org/devtools/backend/protocol.html#source-locations>
122    lines: BTreeSet<u32>,
123}
124
125#[derive(Serialize)]
126struct GetBreakpointPositionsCompressedReply {
127    from: String,
128    // Column numbers are in UTF-16 code units, not Unicode scalar values or grapheme clusters.
129    // Line number are one-based. Column numbers are zero-based.
130    // FIXME: the docs say column numbers are one-based, but this appears to be incorrect.
131    // <https://firefox-source-docs.mozilla.org/devtools/backend/protocol.html#source-locations>
132    positions: BTreeMap<u32, BTreeSet<u32>>,
133}
134
135#[derive(Deserialize)]
136struct GetBreakpointPositionsQuery {
137    start: BreakpointRequestLocation,
138    end: BreakpointRequestLocation,
139}
140
141#[derive(Deserialize)]
142struct GetBreakpointPositionsRequest {
143    query: GetBreakpointPositionsQuery,
144}
145
146impl SourceActor {
147    #[expect(clippy::too_many_arguments)]
148    pub fn register(
149        registry: &ActorRegistry,
150        pipeline_id: PipelineId,
151        url: ServoUrl,
152        content: Option<String>,
153        content_type: Option<String>,
154        spidermonkey_id: u32,
155        introduction_type: String,
156        script_sender: GenericSender<DevtoolScriptControlMsg>,
157    ) -> Arc<Self> {
158        let name = new_actor_name::<Self>();
159        let actor = Self {
160            name: name.clone(),
161            url,
162            content: AtomicRefCell::new(content),
163            content_type,
164            is_black_boxed: false,
165            spidermonkey_id,
166            introduction_type,
167            script_sender,
168        };
169        registry.register_source_actor(pipeline_id, &name);
170        registry.register::<Self>(actor)
171    }
172
173    pub fn source_form(&self) -> SourceForm {
174        SourceForm {
175            actor: self.name.clone(),
176            url: self.url.to_string(),
177            is_black_boxed: self.is_black_boxed,
178            introduction_type: self.introduction_type.clone(),
179        }
180    }
181}
182
183impl Actor for SourceActor {
184    fn name(&self) -> &str {
185        &self.name
186    }
187
188    fn handle_message(
189        &self,
190        request: ClientRequest,
191        _registry: &ActorRegistry,
192        msg_type: &str,
193        msg: &Map<String, Value>,
194        _id: StreamId,
195    ) -> Result<(), ActorError> {
196        match msg_type {
197            // Client has requested contents of the source.
198            "source" => {
199                let reply = SourceContentReply {
200                    from: self.name().into(),
201                    content_type: self.content_type.clone(),
202                    // TODO: if needed, fetch the page again, in the same way as in the original request.
203                    // Fetch it from cache, even if the original request was non-idempotent (e.g. POST).
204                    // If we can’t fetch it from cache, we should probably give up, because with a real
205                    // fetch, the server could return a different response.
206                    // TODO: do we want to wait instead of giving up immediately, in cases where the content could
207                    // become available later (e.g. after a fetch)?
208                    source: self
209                        .content
210                        .borrow()
211                        .as_deref()
212                        .unwrap_or("<!-- not available; please reload! -->")
213                        .to_owned(),
214                };
215                request.reply_final(&reply)?
216            },
217            // Client wants to know which lines can have breakpoints.
218            // Sent when opening a source in the Sources panel, and controls whether the line numbers can be clicked.
219            "getBreakableLines" => {
220                let Some((tx, rx)) = channel() else {
221                    return Err(ActorError::Internal);
222                };
223                self.script_sender
224                    .send(DevtoolScriptControlMsg::GetPossibleBreakpoints(
225                        self.spidermonkey_id,
226                        tx,
227                    ))
228                    .map_err(|_| ActorError::Internal)?;
229                let result = rx.recv().map_err(|_| ActorError::Internal)?;
230                let lines = result
231                    .into_iter()
232                    .map(|location| location.line_number)
233                    .collect::<BTreeSet<_>>();
234                let reply = GetBreakableLinesReply {
235                    from: self.name().into(),
236                    lines,
237                };
238                request.reply_final(&reply)?
239            },
240            // Client wants to know which columns in the line can have breakpoints.
241            // Sent when the user tries to set a breakpoint by clicking a line number in a source.
242            "getBreakpointPositionsCompressed" => {
243                let query =
244                    serde_json::from_value::<GetBreakpointPositionsRequest>(msg.clone().into())
245                        .ok()
246                        .map(|msg| (msg.query.start, msg.query.end));
247
248                let (tx, rx) = channel().ok_or(ActorError::Internal)?;
249                self.script_sender
250                    .send(DevtoolScriptControlMsg::GetPossibleBreakpoints(
251                        self.spidermonkey_id,
252                        tx,
253                    ))
254                    .map_err(|_| ActorError::Internal)?;
255                let result = rx.recv().map_err(|_| ActorError::Internal)?;
256
257                let mut positions: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::default();
258                for location in result {
259                    // Line number are one-based. Column numbers are zero-based.
260                    // FIXME: the docs say column numbers are one-based, but this appears to be incorrect.
261                    // <https://firefox-source-docs.mozilla.org/devtools/backend/protocol.html#source-locations>
262                    if query.as_ref().is_some_and(|(start, end)| {
263                        location.line_number < start.line || location.line_number > end.line
264                    }) {
265                        continue;
266                    }
267                    positions
268                        .entry(location.line_number)
269                        .or_default()
270                        .insert(location.column_number - 1);
271                }
272                let reply = GetBreakpointPositionsCompressedReply {
273                    from: self.name().into(),
274                    positions,
275                };
276                request.reply_final(&reply)?
277            },
278            _ => return Err(ActorError::UnrecognizedPacketType),
279        };
280        Ok(())
281    }
282}
283
284impl SourceActor {
285    pub fn find_offset(&self, line: u32, column: u32) -> Option<(u32, u32)> {
286        let (tx, rx) = channel()?;
287        self.script_sender
288            .send(DevtoolScriptControlMsg::GetPossibleBreakpoints(
289                self.spidermonkey_id,
290                tx,
291            ))
292            .ok()?;
293        let result = rx.recv().ok()?;
294        for location in result {
295            // Line number are one-based. Column numbers are zero-based.
296            // FIXME: the docs say column numbers are one-based, but this appears to be incorrect.
297            // <https://firefox-source-docs.mozilla.org/devtools/backend/protocol.html#source-locations>
298            if location.line_number == line && location.column_number - 1 == column {
299                return Some((location.script_id, location.offset));
300            }
301        }
302        None
303    }
304}