aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-25 12:55:27 +0900
committernsfisis <nsfisis@gmail.com>2026-06-25 23:47:47 +0900
commite971877cc24563ae9983413849edde00601903b0 (patch)
tree4b2c0c8420efcb7e6e422fb78261524d9e8102ba
parente7897eb0136624466ef47526c9548dc56624351c (diff)
downloadphp-shirabe-e971877cc24563ae9983413849edde00601903b0.tar.gz
php-shirabe-e971877cc24563ae9983413849edde00601903b0.tar.zst
php-shirabe-e971877cc24563ae9983413849edde00601903b0.zip
feat(json): port JsonManipulator key/sub-node methods to hand-written scanner
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/shirabe/src/json/json_grammar.rs96
-rw-r--r--crates/shirabe/src/json/json_manipulator.rs189
2 files changed, 181 insertions, 104 deletions
diff --git a/crates/shirabe/src/json/json_grammar.rs b/crates/shirabe/src/json/json_grammar.rs
index 8eb4237..ce7ce86 100644
--- a/crates/shirabe/src/json/json_grammar.rs
+++ b/crates/shirabe/src/json/json_grammar.rs
@@ -176,6 +176,87 @@ pub fn scan_object(b: &[u8], p: usize) -> Option<usize> {
}
}
+/// The kind of value a top-level key must hold for a match to be accepted, mirroring whether the
+/// PHP pattern captures `(?&json)`, `(?&object)` or `(?&array)`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ValueKind {
+ Json,
+ Object,
+ Array,
+}
+
+/// A located top-level key/value within a JSON object's text. All fields are byte offsets into the
+/// scanned contents:
+/// - `key_pos`: where the `"key"` token begins (after `\s* { \s*` and any preceding pairs)
+/// - `value_pos`: where the value begins (after `"key" \s* : \s*`)
+/// - `value_end`: just past the value
+#[derive(Debug, Clone, Copy)]
+pub struct KeyMatch {
+ pub key_pos: usize,
+ pub value_pos: usize,
+ pub value_end: usize,
+}
+
+/// Locates a top-level key whose token text is exactly `encoded_key` (e.g. `"config"`), reproducing
+/// the JsonManipulator patterns of the form
+/// `\s* \{ \s* (?: (?&string) \s* : (?&json) \s* , \s* )*? KEY \s* : \s* (value)`.
+///
+/// Returns `None` when the object cannot be parsed up to the key, or the target value is not of the
+/// requested `kind` (matching how the PCRE grammar would fail and the caller would abort).
+pub fn find_top_level_key(
+ contents: &[u8],
+ encoded_key: &[u8],
+ kind: ValueKind,
+) -> Option<KeyMatch> {
+ let mut i = skip_ws(contents, 0);
+ if contents.get(i) != Some(&b'{') {
+ return None;
+ }
+ i = skip_ws(contents, i + 1);
+ loop {
+ // Each top-level entry must start with a string key.
+ if contents.get(i) != Some(&b'"') {
+ return None;
+ }
+ let key_pos = i;
+ let key_end = scan_string(contents, i)?;
+ let is_target = &contents[key_pos..key_end] == encoded_key;
+
+ let colon = skip_ws(contents, key_end);
+ if contents.get(colon) != Some(&b':') {
+ return None;
+ }
+ let value_pos = skip_ws(contents, colon + 1);
+
+ if is_target {
+ let value_end = match kind {
+ ValueKind::Json => scan_value(contents, value_pos),
+ ValueKind::Object if contents.get(value_pos) == Some(&b'{') => {
+ scan_object(contents, value_pos)
+ }
+ ValueKind::Array if contents.get(value_pos) == Some(&b'[') => {
+ scan_array(contents, value_pos)
+ }
+ _ => None,
+ };
+ // Keys are unique, so a wrong-kind target cannot match later either.
+ return value_end.map(|value_end| KeyMatch {
+ key_pos,
+ value_pos,
+ value_end,
+ });
+ }
+
+ // A non-target entry must be a complete `key : value ,` pair to keep scanning.
+ let value_end = scan_value(contents, value_pos)?;
+ let comma = skip_ws(contents, value_end);
+ if contents.get(comma) != Some(&b',') {
+ return None;
+ }
+ i = skip_ws(contents, comma + 1);
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -222,4 +303,19 @@ mod tests {
// scan_value reports the end of the value, leaving trailing content.
assert_eq!(scan_value(br#"{"a": 1}, "rest""#, 0), Some(8));
}
+
+ #[test]
+ fn finds_top_level_key() {
+ let c = "{\n \"foo\": \"bar\",\n \"require\": {\n \"a\": \"1\"\n }\n}";
+ let m = find_top_level_key(c.as_bytes(), br#""require""#, ValueKind::Object).unwrap();
+ assert_eq!(&c[m.key_pos..m.value_pos], "\"require\": ");
+ assert_eq!(&c[m.value_pos..m.value_end], "{\n \"a\": \"1\"\n }");
+ // first key
+ let m2 = find_top_level_key(c.as_bytes(), br#""foo""#, ValueKind::Json).unwrap();
+ assert_eq!(&c[m2.value_pos..m2.value_end], "\"bar\"");
+ // missing key
+ assert!(find_top_level_key(c.as_bytes(), br#""nope""#, ValueKind::Json).is_none());
+ // wrong kind
+ assert!(find_top_level_key(c.as_bytes(), br#""foo""#, ValueKind::Array).is_none());
+ }
}
diff --git a/crates/shirabe/src/json/json_manipulator.rs b/crates/shirabe/src/json/json_manipulator.rs
index 30ceedb..73b70b9 100644
--- a/crates/shirabe/src/json/json_manipulator.rs
+++ b/crates/shirabe/src/json/json_manipulator.rs
@@ -11,6 +11,7 @@ use shirabe_php_shim::{
};
use crate::json::JsonFile;
+use crate::json::json_grammar::{self, ValueKind};
use crate::repository::PlatformRepository;
#[derive(Debug)]
@@ -659,18 +660,17 @@ impl JsonManipulator {
}
// main node content not match-able
- let node_regex = format!(
- "{{{}^(?P<start> \\s* \\{{ \\s* (?: (?&string) \\s* : (?&json) \\s* , \\s* )*?{}\\s*:\\s*)(?P<content>(?&object))(?P<end>.*)}}sx",
- Self::DEFINES,
- preg_quote(&JsonFile::encode(main_node), None)
- );
-
- let mut match_map: IndexMap<String, String> = IndexMap::new();
- if !Preg::is_match_named(&node_regex, &self.contents, &mut match_map) {
- return Ok(false);
- }
-
- let mut children = match_map.get("content").cloned().unwrap_or_default();
+ let node = match json_grammar::find_top_level_key(
+ self.contents.as_bytes(),
+ JsonFile::encode(main_node).as_bytes(),
+ ValueKind::Object,
+ ) {
+ Some(node) => node,
+ None => return Ok(false),
+ };
+ let node_start = self.contents[..node.value_pos].to_string();
+ let node_end = self.contents[node.value_end..].to_string();
+ let mut children = self.contents[node.value_pos..node.value_end].to_string();
// invalid match due to un-regexable content, abort
if json_decode(&children, false)?.is_null()
|| json_decode(&children, false)?.as_bool() == Some(false)
@@ -678,47 +678,34 @@ impl JsonManipulator {
return Ok(false);
}
- // child exists
- let child_regex = format!(
- "{{{}(?P<start>\"{}\"\\s*:\\s*)(?P<content>(?&json))(?P<end>,?)}}x",
- Self::DEFINES,
- preg_quote(&name_owned, None)
+ // child exists. The child pattern looks for the raw `"name"` key token within the children
+ // object and rewrites its value in place; the surrounding `"name"\s*:\s*` and optional comma
+ // are preserved as-is.
+ let child_key = format!("\"{}\"", name_owned);
+ let child = json_grammar::find_top_level_key(
+ children.as_bytes(),
+ child_key.as_bytes(),
+ ValueKind::Json,
);
- let mut child_match_map: IndexMap<String, String> = IndexMap::new();
- if Preg::is_match_named(&child_regex, &children, &mut child_match_map) {
- let value_capture = value.clone();
- let sub_name_capture = sub_name.clone();
- let formatter = ManipulatorFormatter {
- newline: self.newline.clone(),
- indent: self.indent.clone(),
- };
- children = Preg::replace_callback(
- &child_regex,
- move |matches: &IndexMap<CaptureKey, String>| -> String {
- let content_key = CaptureKey::ByName("content".to_string());
- let start_key = CaptureKey::ByName("start".to_string());
- let end_key = CaptureKey::ByName("end".to_string());
- let mut value_local = value_capture.clone();
- if sub_name_capture.is_some() && matches.get(&content_key).is_some() {
- let mut cur_val = json_decode(matches.get(&content_key).unwrap(), true)
- .unwrap_or(PhpMixed::Null);
- if !is_array(&cur_val) {
- cur_val = PhpMixed::Array(IndexMap::new());
- }
- if let Some(arr) = cur_val.as_array_mut() {
- arr.insert(sub_name_capture.clone().unwrap(), value_local.clone());
- }
- value_local = cur_val;
- }
-
- format!(
- "{}{}{}",
- matches.get(&start_key).cloned().unwrap_or_default(),
- formatter.format(&value_local, 1, false).unwrap_or_default(),
- matches.get(&end_key).cloned().unwrap_or_default()
- )
- },
- &children,
+ if let Some(cm) = child {
+ let content_str = children[cm.value_pos..cm.value_end].to_string();
+ let mut value_local = value.clone();
+ if sub_name.is_some() {
+ let mut cur_val = json_decode(&content_str, true).unwrap_or(PhpMixed::Null);
+ if !is_array(&cur_val) {
+ cur_val = PhpMixed::Array(IndexMap::new());
+ }
+ if let Some(arr) = cur_val.as_array_mut() {
+ arr.insert(sub_name.clone().unwrap(), value_local.clone());
+ }
+ value_local = cur_val;
+ }
+ let formatted = self.format(&value_local, 1, false)?;
+ children = format!(
+ "{}{}{}",
+ &children[..cm.value_pos],
+ formatted,
+ &children[cm.value_end..]
);
} else {
let mut leading_match: IndexMap<String, String> = IndexMap::new();
@@ -809,23 +796,7 @@ impl JsonManipulator {
}
}
- let children_owned = children;
- self.contents = Preg::replace_callback(
- &node_regex,
- move |m: &IndexMap<CaptureKey, String>| -> String {
- format!(
- "{}{}{}",
- m.get(&CaptureKey::ByName("start".to_string()))
- .cloned()
- .unwrap_or_default(),
- children_owned,
- m.get(&CaptureKey::ByName("end".to_string()))
- .cloned()
- .unwrap_or_default()
- )
- },
- &self.contents,
- );
+ self.contents = format!("{}{}{}", node_start, children, node_end);
Ok(true)
}
@@ -1395,27 +1366,29 @@ impl JsonManipulator {
let content = self.format(&content, 0, false)?;
// key exists already
- let regex = format!(
- "{{{}^(?P<start>\\s*\\{{\\s*(?:(?&string)\\s*:\\s*(?&json)\\s*,\\s*)*?)(?P<key>{}\\s*:\\s*(?&json))(?P<end>.*)}}sx",
- Self::DEFINES,
- preg_quote(&JsonFile::encode(key), None)
- );
- let mut matches: IndexMap<String, String> = IndexMap::new();
- if decoded.as_array().and_then(|a| a.get(key)).is_some()
- && Preg::is_match_named(&regex, &self.contents, &mut matches)
- {
+ let encoded_key = JsonFile::encode(key);
+ let key_match = if decoded.as_array().and_then(|a| a.get(key)).is_some() {
+ json_grammar::find_top_level_key(
+ self.contents.as_bytes(),
+ encoded_key.as_bytes(),
+ ValueKind::Json,
+ )
+ } else {
+ None
+ };
+ if let Some(m) = key_match {
// invalid match due to un-regexable content, abort
- let key_match = matches.get("key").cloned().unwrap_or_default();
- if json_decode(&format!("{{{}}}", key_match), false)?.is_null() {
+ let key_capture = &self.contents[m.key_pos..m.value_end];
+ if json_decode(&format!("{{{}}}", key_capture), false)?.is_null() {
return Ok(false);
}
self.contents = format!(
"{}{}: {}{}",
- matches.get("start").cloned().unwrap_or_default(),
- JsonFile::encode(key),
+ &self.contents[..m.key_pos],
+ encoded_key,
content,
- matches.get("end").cloned().unwrap_or_default()
+ &self.contents[m.value_end..]
);
return Ok(true);
@@ -1475,22 +1448,30 @@ impl JsonManipulator {
}
// key exists already
- let regex = format!(
- "{{{}^(?P<start>\\s*\\{{\\s*(?:(?&string)\\s*:\\s*(?&json)\\s*,\\s*)*?)(?P<removal>{}\\s*:\\s*(?&json))\\s*,?\\s*(?P<end>.*)}}sx",
- Self::DEFINES,
- preg_quote(&JsonFile::encode(key), None)
+ let encoded_key = JsonFile::encode(key);
+ let key_match = json_grammar::find_top_level_key(
+ self.contents.as_bytes(),
+ encoded_key.as_bytes(),
+ ValueKind::Json,
);
- let mut matches: IndexMap<String, String> = IndexMap::new();
- if Preg::is_match_named(&regex, &self.contents, &mut matches) {
+ if let Some(m) = key_match {
// invalid match due to un-regexable content, abort
- let removal = matches.get("removal").cloned().unwrap_or_default();
+ let removal = &self.contents[m.key_pos..m.value_end];
if json_decode(&format!("{{{}}}", removal), false)?.is_null() {
return Ok(false);
}
+ // The pattern consumes `\s*,?\s*` between the removed key and the rest (`end`).
+ let cb = self.contents.as_bytes();
+ let mut e = json_grammar::skip_ws(cb, m.value_end);
+ if cb.get(e) == Some(&b',') {
+ e += 1;
+ }
+ e = json_grammar::skip_ws(cb, e);
+
// check that we are not leaving a dangling comma on the previous line if the last line was removed
- let mut start = matches.get("start").cloned().unwrap_or_default();
- let end = matches.get("end").cloned().unwrap_or_default();
+ let mut start = self.contents[..m.key_pos].to_string();
+ let end = self.contents[e..].to_string();
if Preg::is_match3("#,\\s*$#", &start, None) && Preg::is_match3("#^\\}$#", &end, None) {
start = rtrim(
&Preg::replace("#,(\\s*)$#", "$1", &start),
@@ -1517,24 +1498,24 @@ impl JsonManipulator {
return Ok(true);
}
- let regex = format!(
- "{{{}^(?P<start>\\s*\\{{\\s*(?:(?&string)\\s*:\\s*(?&json)\\s*,\\s*)*?{}\\s*:\\s*)(?P<removal>\\{{(?P<removal_space>\\s*+)\\}})(?P<end>\\s*,?\\s*.*)}}sx",
- Self::DEFINES,
- preg_quote(&JsonFile::encode(key), None)
- );
- let mut matches: IndexMap<String, String> = IndexMap::new();
- if Preg::is_match_named(&regex, &self.contents, &mut matches) {
+ // Match the key only when its value is an empty object `{ <space> }`.
+ let encoded_key = JsonFile::encode(key);
+ let cb = self.contents.as_bytes();
+ let key_match = json_grammar::find_top_level_key(cb, encoded_key.as_bytes(), ValueKind::Object)
+ .filter(|m| json_grammar::skip_ws(cb, m.value_pos + 1) == m.value_end - 1);
+ if let Some(m) = key_match {
// invalid match due to un-regexable content, abort
- let removal = matches.get("removal").cloned().unwrap_or_default();
- if json_decode(&removal, false)?.as_bool() == Some(false) {
+ let removal = &self.contents[m.value_pos..m.value_end];
+ if json_decode(removal, false)?.as_bool() == Some(false) {
return Ok(false);
}
+ let removal_space = &self.contents[m.value_pos + 1..m.value_end - 1];
self.contents = format!(
"{}[{}]{}",
- matches.get("start").cloned().unwrap_or_default(),
- matches.get("removal_space").cloned().unwrap_or_default(),
- matches.get("end").cloned().unwrap_or_default()
+ &self.contents[..m.value_pos],
+ removal_space,
+ &self.contents[m.value_end..]
);
return Ok(true);